新增举报摄影师模块并接入首页路由,桌面应用名改为随心瞰商家版。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -16,6 +16,10 @@ enum AppDesign {
|
|||||||
static let placeholder = Color(hex: 0xA8B2C1)
|
static let placeholder = Color(hex: 0xA8B2C1)
|
||||||
static let success = Color(hex: 0x14964A)
|
static let success = Color(hex: 0x14964A)
|
||||||
static let warning = Color(hex: 0xFF7B00)
|
static let warning = Color(hex: 0xFF7B00)
|
||||||
|
static let background = Color(hex: 0xF6F8FB)
|
||||||
|
static let inputBorder = Color(hex: 0xDDE3EA)
|
||||||
|
static let border = Color(hex: 0xE5E7EB)
|
||||||
|
static let danger = Color(hex: 0xEF4444)
|
||||||
|
|
||||||
// MARK: - 订单列表专用色值(对齐 Android OrderListScreen)
|
// MARK: - 订单列表专用色值(对齐 Android OrderListScreen)
|
||||||
|
|
||||||
|
|||||||
@ -80,7 +80,9 @@ allFunctions = 顶层权限
|
|||||||
.映射为 HomeMenuItem
|
.映射为 HomeMenuItem
|
||||||
```
|
```
|
||||||
|
|
||||||
`androidHomeMenuURIs` 与 Android `Constants.menuList` 登记 URI 一致。以下典型 URI **不会**出现在「全部功能」页,即使接口返回了顶层权限:
|
`androidHomeMenuURIs` 与 Android `Constants.menuList` 登记 URI 一致。已登记且会出现在「全部功能」页的 URI 包括 `location_report`、`photographer_report`(举报摄影师)、`wallet`、`message_center` 等。
|
||||||
|
|
||||||
|
以下典型 URI **不会**出现在「全部功能」页,即使接口返回了顶层权限:
|
||||||
|
|
||||||
- `basic_info`、`photographer_stats`、`photographer_orders`
|
- `basic_info`、`photographer_stats`、`photographer_orders`
|
||||||
- `location_info`、`scan_qr`、`payment_qr`
|
- `location_info`、`scan_qr`、`payment_qr`
|
||||||
|
|||||||
@ -61,3 +61,5 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
|||||||
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`cooperation_order` 已由 `Features/CooperationOrder` 接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管,`pm`、`project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation`、`photographer_invite`、`invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement` 和 `scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue` 和 `queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management` 和 `live_album` 已由 `Features/Live` 接管,`operating-area` 已由 `Features/OperatingArea` 接管,`pilot_cert` 已由 `Features/PilotCertification` 接管。
|
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`cooperation_order` 已由 `Features/CooperationOrder` 接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管,`pm`、`project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation`、`photographer_invite`、`invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement` 和 `scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue` 和 `queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management` 和 `live_album` 已由 `Features/Live` 接管,`operating-area` 已由 `Features/OperatingArea` 接管,`pilot_cert` 已由 `Features/PilotCertification` 接管。
|
||||||
|
|
||||||
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
||||||
|
|
||||||
|
`photographer_report` 已由 `Features/WildPhotographerReport` 接管。
|
||||||
|
|||||||
@ -55,6 +55,7 @@ enum HomeRoute: Hashable {
|
|||||||
case punchPointQR(id: Int, title: String, qrURL: String)
|
case punchPointQR(id: Int, title: String, qrURL: String)
|
||||||
case locationReport
|
case locationReport
|
||||||
case locationReportHistory
|
case locationReportHistory
|
||||||
|
case photographerReport
|
||||||
case depositOrders
|
case depositOrders
|
||||||
case withdrawalAudit
|
case withdrawalAudit
|
||||||
case cooperationOrder
|
case cooperationOrder
|
||||||
|
|||||||
@ -46,6 +46,7 @@ enum HomeMenuRouter {
|
|||||||
"pm_manager": "店铺项目管理",
|
"pm_manager": "店铺项目管理",
|
||||||
"project_edit": "项目管理",
|
"project_edit": "项目管理",
|
||||||
"location_report": "位置上报",
|
"location_report": "位置上报",
|
||||||
|
"photographer_report": "举报摄影师",
|
||||||
"registration_invitation": "注册邀请",
|
"registration_invitation": "注册邀请",
|
||||||
"store": "店铺管理",
|
"store": "店铺管理",
|
||||||
"fly": "飞行管理",
|
"fly": "飞行管理",
|
||||||
@ -131,6 +132,8 @@ enum HomeMenuRouter {
|
|||||||
return .destination(.locationReport)
|
return .destination(.locationReport)
|
||||||
case "location_report_history":
|
case "location_report_history":
|
||||||
return .destination(.locationReportHistory)
|
return .destination(.locationReportHistory)
|
||||||
|
case "photographer_report":
|
||||||
|
return .destination(.photographerReport)
|
||||||
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
|
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
|
||||||
return .destination(.depositOrders)
|
return .destination(.depositOrders)
|
||||||
case "withdrawal_audit":
|
case "withdrawal_audit":
|
||||||
@ -216,6 +219,7 @@ enum HomeMenuRouter {
|
|||||||
"pm_manager",
|
"pm_manager",
|
||||||
"project_edit",
|
"project_edit",
|
||||||
"location_report",
|
"location_report",
|
||||||
|
"photographer_report",
|
||||||
"registration_invitation",
|
"registration_invitation",
|
||||||
"photographer_invite",
|
"photographer_invite",
|
||||||
"invite_record",
|
"invite_record",
|
||||||
@ -294,6 +298,8 @@ enum HomeMenuRouter {
|
|||||||
return "注册邀请"
|
return "注册邀请"
|
||||||
case "location_report":
|
case "location_report":
|
||||||
return "位置上报"
|
return "位置上报"
|
||||||
|
case "photographer_report":
|
||||||
|
return "举报摄影师"
|
||||||
case "pm", "project_edit":
|
case "pm", "project_edit":
|
||||||
return "项目管理"
|
return "项目管理"
|
||||||
case "pm_manager":
|
case "pm_manager":
|
||||||
|
|||||||
@ -30,6 +30,7 @@ struct HomeCommonMenuStore {
|
|||||||
"travel_album",
|
"travel_album",
|
||||||
"pm",
|
"pm",
|
||||||
"location_report",
|
"location_report",
|
||||||
|
"photographer_report",
|
||||||
"registration_invitation",
|
"registration_invitation",
|
||||||
"store",
|
"store",
|
||||||
"fly",
|
"fly",
|
||||||
|
|||||||
@ -54,6 +54,8 @@ enum HomeIconCatalog {
|
|||||||
"circle.grid.2x2.fill"
|
"circle.grid.2x2.fill"
|
||||||
case "location_report", "location_report_history":
|
case "location_report", "location_report_history":
|
||||||
"mappin.circle.fill"
|
"mappin.circle.fill"
|
||||||
|
case "photographer_report":
|
||||||
|
"shield.fill"
|
||||||
case "registration_invitation", "photographer_invite":
|
case "registration_invitation", "photographer_invite":
|
||||||
"envelope.open.fill"
|
"envelope.open.fill"
|
||||||
case "store":
|
case "store":
|
||||||
|
|||||||
@ -86,6 +86,8 @@ extension HomeRoute {
|
|||||||
LocationReportView()
|
LocationReportView()
|
||||||
case .locationReportHistory:
|
case .locationReportHistory:
|
||||||
LocationReportHistoryView()
|
LocationReportHistoryView()
|
||||||
|
case .photographerReport:
|
||||||
|
WildPhotographerReportHomeView()
|
||||||
case .depositOrders:
|
case .depositOrders:
|
||||||
DepositOrderEntryView()
|
DepositOrderEntryView()
|
||||||
case .withdrawalAudit:
|
case .withdrawalAudit:
|
||||||
|
|||||||
@ -0,0 +1,349 @@
|
|||||||
|
//
|
||||||
|
// WildPhotographerReportModels.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/7/1.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreLocation
|
||||||
|
import MapKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 举报摄影师页面状态,用于演示加载、空态和异常态。
|
||||||
|
enum WildReportPageState: String, CaseIterable, Identifiable {
|
||||||
|
case normal
|
||||||
|
case loading
|
||||||
|
case empty
|
||||||
|
case error
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .normal: "正常"
|
||||||
|
case .loading: "加载"
|
||||||
|
case .empty: "空状态"
|
||||||
|
case .error: "异常"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报附件类型,区分现场图片、视频和支付截图。
|
||||||
|
enum WildReportAttachmentKind {
|
||||||
|
case image
|
||||||
|
case video
|
||||||
|
case payment
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报类型,用于提交页单选并写入 Mock 举报记录。
|
||||||
|
enum WildReportType: String, CaseIterable, Identifiable, Hashable {
|
||||||
|
case suspectedWild = "疑似打野"
|
||||||
|
case noWorkBadge = "未戴工牌"
|
||||||
|
case other = "其他"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var iconName: String {
|
||||||
|
switch self {
|
||||||
|
case .suspectedWild:
|
||||||
|
"person.crop.circle.badge.exclamationmark"
|
||||||
|
case .noWorkBadge:
|
||||||
|
"lanyardcard"
|
||||||
|
case .other:
|
||||||
|
"ellipsis.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报附件实体,表示一条演示用的上传项。
|
||||||
|
struct WildReportAttachment: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let kind: WildReportAttachmentKind
|
||||||
|
let title: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报处理状态。
|
||||||
|
enum WildReportStatus: String, Hashable, CaseIterable {
|
||||||
|
case pending = "待处理"
|
||||||
|
case processing = "处理中"
|
||||||
|
case processed = "已处理"
|
||||||
|
|
||||||
|
var displayColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .pending: AppDesign.warning
|
||||||
|
case .processing: AppDesign.primary
|
||||||
|
case .processed: AppDesign.success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 我的举报列表筛选条件。
|
||||||
|
enum WildReportListFilter: String, CaseIterable, Identifiable {
|
||||||
|
case all = "全部"
|
||||||
|
case pending = "待处理"
|
||||||
|
case processing = "处理中"
|
||||||
|
case processed = "已处理"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WildReportStatus {
|
||||||
|
var listDisplayText: String { rawValue }
|
||||||
|
var listDisplayColor: Color { displayColor }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 补充证据记录实体。
|
||||||
|
struct WildReportSupplementaryEvidence: Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let submitTime: String
|
||||||
|
let text: String
|
||||||
|
let imageCount: Int
|
||||||
|
let videoCount: Int
|
||||||
|
|
||||||
|
var summaryText: String {
|
||||||
|
var parts: [String] = []
|
||||||
|
if !text.isEmpty { parts.append(text) }
|
||||||
|
if imageCount > 0 { parts.append("\(imageCount)张图片") }
|
||||||
|
if videoCount > 0 { parts.append("\(videoCount)段视频") }
|
||||||
|
return parts.isEmpty ? "补充证据" : parts.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报位置解析工具。
|
||||||
|
enum WildReportLocationParser {
|
||||||
|
/// 将「景区 · 详细地址」拆成两段展示文案。
|
||||||
|
static func split(_ location: String) -> (scenic: String, detail: String) {
|
||||||
|
let parts = location
|
||||||
|
.components(separatedBy: " · ")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
if parts.count >= 2 {
|
||||||
|
return (parts[0], parts[1])
|
||||||
|
}
|
||||||
|
return (location, location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 附近风险点实体。
|
||||||
|
struct WildReportRiskPoint: Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let title: String
|
||||||
|
let location: String
|
||||||
|
let distance: String
|
||||||
|
let riskLevel: String
|
||||||
|
let evidenceCount: Int
|
||||||
|
let latestTime: String
|
||||||
|
let description: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报记录实体。
|
||||||
|
struct WildReportRecord: Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let reportType: WildReportType
|
||||||
|
let status: WildReportStatus
|
||||||
|
let location: String
|
||||||
|
let submitTime: String
|
||||||
|
let description: String
|
||||||
|
let wechatID: String
|
||||||
|
let phoneNumber: String
|
||||||
|
let imageCount: Int
|
||||||
|
let videoCount: Int
|
||||||
|
let handlerInfo: WildReportHandlerInfo?
|
||||||
|
|
||||||
|
var contactDisplayText: String {
|
||||||
|
switch (wechatID.isEmpty, phoneNumber.isEmpty) {
|
||||||
|
case (true, true): "未填写"
|
||||||
|
case (false, false): "\(wechatID) / \(phoneNumber)"
|
||||||
|
case (false, true): wechatID
|
||||||
|
case (true, false): phoneNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理完成后的媒体项。
|
||||||
|
struct WildReportCompletionMediaItem: Identifiable, Hashable {
|
||||||
|
enum Kind: Hashable {
|
||||||
|
case image
|
||||||
|
case video
|
||||||
|
}
|
||||||
|
|
||||||
|
let id: String
|
||||||
|
let kind: Kind
|
||||||
|
let coverURL: String
|
||||||
|
let duration: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报处理人信息实体。
|
||||||
|
struct WildReportHandlerInfo: Hashable {
|
||||||
|
let handlerName: String
|
||||||
|
let handlerPhone: String
|
||||||
|
let processingAt: String
|
||||||
|
let processedAt: String
|
||||||
|
let processOpinion: String
|
||||||
|
let completionMedia: [WildReportCompletionMediaItem]
|
||||||
|
|
||||||
|
var maskedHandlerPhone: String {
|
||||||
|
guard handlerPhone.count == 11, handlerPhone.allSatisfy(\.isNumber) else { return handlerPhone }
|
||||||
|
return "\(handlerPhone.prefix(3))****\(handlerPhone.suffix(4))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var completionMediaSummary: String {
|
||||||
|
let imageCount = completionMedia.filter { $0.kind == .image }.count
|
||||||
|
let videoCount = completionMedia.filter { $0.kind == .video }.count
|
||||||
|
switch (imageCount, videoCount) {
|
||||||
|
case (0, 0): return "0项"
|
||||||
|
case (_, 0): return "\(imageCount)张"
|
||||||
|
case (0, _): return "\(videoCount)段"
|
||||||
|
default: return "\(imageCount)张 \(videoCount)段"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报进度节点状态。
|
||||||
|
enum WildReportProgressStepState {
|
||||||
|
case completed
|
||||||
|
case current
|
||||||
|
case pending
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报进度时间轴节点。
|
||||||
|
struct WildReportProgressStep: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let timeText: String
|
||||||
|
let state: WildReportProgressStepState
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报人材料预览项。
|
||||||
|
enum WildReportReporterMediaItem: Identifiable {
|
||||||
|
case image(index: Int)
|
||||||
|
case video(index: Int)
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .image(let index): "reporter-image-\(index)"
|
||||||
|
case .video(let index): "reporter-video-\(index)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报提交结果,用于成功页展示。
|
||||||
|
struct WildReportSubmitResult: Identifiable, Hashable {
|
||||||
|
let record: WildReportRecord
|
||||||
|
let imageCount: Int
|
||||||
|
let videoCount: Int
|
||||||
|
|
||||||
|
var id: String { record.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 风险地图标记类型。
|
||||||
|
enum WildReportMapMarkerKind {
|
||||||
|
case selfLocation
|
||||||
|
case wildPhotographer
|
||||||
|
case processingClue
|
||||||
|
case peerPhotographer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 风险地图媒体预览项。
|
||||||
|
struct WildReportMapMediaItem: Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let systemImage: String
|
||||||
|
let tint: Color
|
||||||
|
let isVideo: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 风险地图网络图片预览包装。
|
||||||
|
struct WildReportPreviewImageURL: Identifiable, Hashable {
|
||||||
|
let url: String
|
||||||
|
var id: String { url }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 雷达地图标记颜色类型。
|
||||||
|
enum WildReportRadarMarkerType: String, Hashable {
|
||||||
|
case blue
|
||||||
|
case green
|
||||||
|
case red
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 雷达线索举报内容条目。
|
||||||
|
struct WildReportRadarReportContent: Hashable {
|
||||||
|
let time: String
|
||||||
|
let content: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 雷达线索处理记录。
|
||||||
|
struct WildReportRadarProcessRecord: Hashable {
|
||||||
|
let handlerName: String
|
||||||
|
let phone: String
|
||||||
|
let maskedPhone: String
|
||||||
|
let statusText: String
|
||||||
|
let processResult: String
|
||||||
|
let processedAt: String?
|
||||||
|
let processingAt: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 雷达线索处理时间轴节点。
|
||||||
|
struct WildReportRadarProcessingTimelineItem: Hashable {
|
||||||
|
let title: String
|
||||||
|
let time: String
|
||||||
|
let status: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 雷达地图活动线索详情。
|
||||||
|
struct WildReportRadarActiveClue: Hashable {
|
||||||
|
let id: String
|
||||||
|
let type: WildReportRadarMarkerType
|
||||||
|
let displayTitle: String
|
||||||
|
let statusText: String
|
||||||
|
let actionText: String
|
||||||
|
let nearestAbnormalText: String?
|
||||||
|
let distance: String?
|
||||||
|
let direction: String?
|
||||||
|
let updatedAt: String?
|
||||||
|
let name: String?
|
||||||
|
let storeName: String?
|
||||||
|
let avatar: String?
|
||||||
|
let summary: String?
|
||||||
|
let detail: String?
|
||||||
|
let distanceText: String?
|
||||||
|
let reporterName: String?
|
||||||
|
let reporterPhone: String?
|
||||||
|
let maskedReporterPhone: String?
|
||||||
|
let reportContents: [WildReportRadarReportContent]
|
||||||
|
let images: [String]
|
||||||
|
let processingStarted: Bool
|
||||||
|
let completed: Bool
|
||||||
|
let record: WildReportRadarProcessRecord?
|
||||||
|
let processingTimeline: [WildReportRadarProcessingTimelineItem]
|
||||||
|
let completionPhoto: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 风险地图标记详情。
|
||||||
|
enum WildReportMapMarkerDetail: Hashable {
|
||||||
|
case activeClue(WildReportRadarActiveClue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 风险地图标记实体。
|
||||||
|
struct WildReportMapMarker: Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let coordinate: CLLocationCoordinate2D
|
||||||
|
let kind: WildReportMapMarkerKind
|
||||||
|
let detail: WildReportMapMarkerDetail
|
||||||
|
|
||||||
|
static func == (lhs: WildReportMapMarker, rhs: WildReportMapMarker) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CLLocationCoordinate2D: @retroactive Equatable {
|
||||||
|
public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool {
|
||||||
|
lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,717 @@
|
|||||||
|
//
|
||||||
|
// WildPhotographerReportMockStore.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/7/1.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreLocation
|
||||||
|
import Foundation
|
||||||
|
import MapKit
|
||||||
|
|
||||||
|
/// 举报摄影师 Mock 数据仓库,统一管理演示种子数据和地图标记构建。
|
||||||
|
enum WildPhotographerReportMockStore {
|
||||||
|
static let fallbackLocation = "黄山风景区 · 光明顶景区"
|
||||||
|
|
||||||
|
static let initialImages = [
|
||||||
|
WildReportAttachment(kind: .image, title: "现场图片1")
|
||||||
|
]
|
||||||
|
|
||||||
|
static let initialVideos = [
|
||||||
|
WildReportAttachment(kind: .video, title: "现场视频1")
|
||||||
|
]
|
||||||
|
|
||||||
|
static let seedRiskPoints = [
|
||||||
|
WildReportRiskPoint(
|
||||||
|
id: 1,
|
||||||
|
title: "光明顶观景台附近",
|
||||||
|
location: "黄山风景区 · 光明顶景区",
|
||||||
|
distance: "120m",
|
||||||
|
riskLevel: "高",
|
||||||
|
evidenceCount: 6,
|
||||||
|
latestTime: "10分钟前",
|
||||||
|
description: "多名游客反馈有疑似未备案摄影师主动揽客,集中在观景台东侧步道附近。"
|
||||||
|
),
|
||||||
|
WildReportRiskPoint(
|
||||||
|
id: 2,
|
||||||
|
title: "迎客松游客出口",
|
||||||
|
location: "黄山风景区 · 玉屏景区",
|
||||||
|
distance: "860m",
|
||||||
|
riskLevel: "中",
|
||||||
|
evidenceCount: 3,
|
||||||
|
latestTime: "32分钟前",
|
||||||
|
description: "疑似人员以低价加急出片为话术引导游客线下付款,建议现场核查。"
|
||||||
|
),
|
||||||
|
WildReportRiskPoint(
|
||||||
|
id: 3,
|
||||||
|
title: "云谷索道排队区",
|
||||||
|
location: "黄山风景区 · 云谷景区",
|
||||||
|
distance: "1.4km",
|
||||||
|
riskLevel: "低",
|
||||||
|
evidenceCount: 2,
|
||||||
|
latestTime: "1小时前",
|
||||||
|
description: "排队区附近存在疑似私下拉客行为,已收到图片和位置线索。"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
static let seedReports = [
|
||||||
|
WildReportRecord(
|
||||||
|
id: "JB20260618001",
|
||||||
|
title: "摄影师举报",
|
||||||
|
reportType: .suspectedWild,
|
||||||
|
status: .processing,
|
||||||
|
location: "那拉提景区 · 空中草原入口附近",
|
||||||
|
submitTime: "2026-06-18 10:24",
|
||||||
|
description: "疑似人员在观景台附近主动向游客售卖拍摄服务,并引导线下转账。",
|
||||||
|
wechatID: "wx_photographer_01",
|
||||||
|
phoneNumber: "13812345678",
|
||||||
|
imageCount: 3,
|
||||||
|
videoCount: 1,
|
||||||
|
handlerInfo: nil
|
||||||
|
),
|
||||||
|
WildReportRecord(
|
||||||
|
id: "JB20260617008",
|
||||||
|
title: "摄影师举报",
|
||||||
|
reportType: .suspectedWild,
|
||||||
|
status: .processed,
|
||||||
|
location: "那拉提景区 · 河谷草原游客中心",
|
||||||
|
submitTime: "2026-06-17 16:42",
|
||||||
|
description: "现场发现疑似未备案摄影师持续向游客推销旅拍套餐。",
|
||||||
|
wechatID: "nalati_photo",
|
||||||
|
phoneNumber: "",
|
||||||
|
imageCount: 2,
|
||||||
|
videoCount: 1,
|
||||||
|
handlerInfo: processedHandlerInfo(
|
||||||
|
handlerName: "赵强",
|
||||||
|
handlerPhone: "15938267120",
|
||||||
|
processingAt: "2026-06-17 17:10",
|
||||||
|
processedAt: "2026-06-17 18:26",
|
||||||
|
processOpinion: "已到场核查并完成劝离,处理结果已同步景区公安。",
|
||||||
|
imageSeedPrefix: "handler-jb008"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
WildReportRecord(
|
||||||
|
id: "JB20260616003",
|
||||||
|
title: "摄影师举报",
|
||||||
|
reportType: .noWorkBadge,
|
||||||
|
status: .pending,
|
||||||
|
location: "那拉提景区 · 盘龙古道停车区",
|
||||||
|
submitTime: "2026-06-16 09:15",
|
||||||
|
description: "停车区附近有摄影师未佩戴工牌开展拍摄服务,已收到图片和位置线索。",
|
||||||
|
wechatID: "",
|
||||||
|
phoneNumber: "13987654321",
|
||||||
|
imageCount: 1,
|
||||||
|
videoCount: 0,
|
||||||
|
handlerInfo: nil
|
||||||
|
),
|
||||||
|
WildReportRecord(
|
||||||
|
id: "JB20260615002",
|
||||||
|
title: "摄影师举报",
|
||||||
|
reportType: .other,
|
||||||
|
status: .processed,
|
||||||
|
location: "那拉提景区 · 雪莲谷步道",
|
||||||
|
submitTime: "2026-06-15 14:30",
|
||||||
|
description: "举报线索经核查未发现异常摄影师服务行为,已归档。",
|
||||||
|
wechatID: "grassland_cam",
|
||||||
|
phoneNumber: "13600001111",
|
||||||
|
imageCount: 1,
|
||||||
|
videoCount: 0,
|
||||||
|
handlerInfo: processedHandlerInfo(
|
||||||
|
handlerName: "李娜",
|
||||||
|
handlerPhone: "18674598210",
|
||||||
|
processingAt: "2026-06-15 15:05",
|
||||||
|
processedAt: "2026-06-15 16:55",
|
||||||
|
processOpinion: "现场核查未发现未备案摄影师,已归档处理。",
|
||||||
|
imageSeedPrefix: "handler-jb002",
|
||||||
|
includeVideo: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
static let seedSupplementaryEvidences: [String: [WildReportSupplementaryEvidence]] = [
|
||||||
|
"JB20260618001": [
|
||||||
|
WildReportSupplementaryEvidence(
|
||||||
|
id: "SE001",
|
||||||
|
submitTime: "2026-06-18 14:20",
|
||||||
|
text: "补充现场视频,对方仍在揽客。",
|
||||||
|
imageCount: 1,
|
||||||
|
videoCount: 1
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 构建已处理举报的处理人信息 Mock 数据。
|
||||||
|
static func processedHandlerInfo(
|
||||||
|
handlerName: String,
|
||||||
|
handlerPhone: String,
|
||||||
|
processingAt: String,
|
||||||
|
processedAt: String,
|
||||||
|
processOpinion: String,
|
||||||
|
imageSeedPrefix: String,
|
||||||
|
includeVideo: Bool = true
|
||||||
|
) -> WildReportHandlerInfo {
|
||||||
|
var media: [WildReportCompletionMediaItem] = [
|
||||||
|
WildReportCompletionMediaItem(
|
||||||
|
id: "\(imageSeedPrefix)-img-1",
|
||||||
|
kind: .image,
|
||||||
|
coverURL: "https://picsum.photos/seed/\(imageSeedPrefix)-1/640/480",
|
||||||
|
duration: nil
|
||||||
|
),
|
||||||
|
WildReportCompletionMediaItem(
|
||||||
|
id: "\(imageSeedPrefix)-img-2",
|
||||||
|
kind: .image,
|
||||||
|
coverURL: "https://picsum.photos/seed/\(imageSeedPrefix)-2/640/480",
|
||||||
|
duration: nil
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if includeVideo {
|
||||||
|
media.append(
|
||||||
|
WildReportCompletionMediaItem(
|
||||||
|
id: "\(imageSeedPrefix)-vid-1",
|
||||||
|
kind: .video,
|
||||||
|
coverURL: "https://picsum.photos/seed/\(imageSeedPrefix)-video/640/480",
|
||||||
|
duration: "00:18"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return WildReportHandlerInfo(
|
||||||
|
handlerName: handlerName,
|
||||||
|
handlerPhone: handlerPhone,
|
||||||
|
processingAt: processingAt,
|
||||||
|
processedAt: processedAt,
|
||||||
|
processOpinion: processOpinion,
|
||||||
|
completionMedia: media
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从已有举报编号中提取最大序号,供新编号递增。
|
||||||
|
static func maxSequence(from reports: [WildReportRecord]) -> Int {
|
||||||
|
reports.compactMap { record in
|
||||||
|
guard record.id.count >= 3 else { return nil }
|
||||||
|
return Int(record.id.suffix(3))
|
||||||
|
}.max() ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析联系方式,区分微信号和手机号。
|
||||||
|
static func parseContact(_ contact: String?) -> (wechat: String, phone: String) {
|
||||||
|
guard let contact else { return ("", "") }
|
||||||
|
let trimmed = contact.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return ("", "") }
|
||||||
|
|
||||||
|
let digits = trimmed.filter(\.isNumber)
|
||||||
|
if digits.count >= 7, trimmed.filter({ $0.isNumber || "+-() ".contains($0) }).count == trimmed.count {
|
||||||
|
return ("", trimmed)
|
||||||
|
}
|
||||||
|
return (trimmed, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
static let reportDateFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let submitTimeFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: - 风险地图 Mock
|
||||||
|
|
||||||
|
static let nalatiCenter = CLLocationCoordinate2D(latitude: 43.382, longitude: 84.032)
|
||||||
|
|
||||||
|
private static let clueID1 = "2026年6月22日-0001"
|
||||||
|
private static let clueID2 = "2026年6月22日-0002"
|
||||||
|
private static let clueID3 = "2026年6月22日-0003"
|
||||||
|
private static let initialProcessingClueIDs: Set<String> = [clueID2]
|
||||||
|
|
||||||
|
/// 根据线索状态返回地图标记类型。
|
||||||
|
static func markerKind(for clue: WildReportRadarActiveClue) -> WildReportMapMarkerKind {
|
||||||
|
switch clue.type {
|
||||||
|
case .blue:
|
||||||
|
return .selfLocation
|
||||||
|
case .green:
|
||||||
|
return .peerPhotographer
|
||||||
|
case .red:
|
||||||
|
if clue.processingStarted && !clue.completed {
|
||||||
|
return .processingClue
|
||||||
|
}
|
||||||
|
return .wildPhotographer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建风险地图全部标记点。
|
||||||
|
static func buildMarkers(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) -> [WildReportMapMarker] {
|
||||||
|
_ = record
|
||||||
|
_ = riskPoints
|
||||||
|
|
||||||
|
let blueCoordinate = CLLocationCoordinate2D(latitude: 43.3779, longitude: 84.0217)
|
||||||
|
let redSeeds: [(id: String, name: String, coordinate: CLLocationCoordinate2D, clue: WildReportRadarActiveClue)] = [
|
||||||
|
(
|
||||||
|
clueID1,
|
||||||
|
"空中草原观景道",
|
||||||
|
CLLocationCoordinate2D(latitude: 43.4068, longitude: 84.0582),
|
||||||
|
WildReportRadarActiveClue(
|
||||||
|
id: clueID1,
|
||||||
|
type: .red,
|
||||||
|
displayTitle: "线索 ID:\(clueID1)",
|
||||||
|
statusText: "疑似打野",
|
||||||
|
actionText: "导航到此",
|
||||||
|
nearestAbnormalText: nil,
|
||||||
|
distance: nil,
|
||||||
|
direction: nil,
|
||||||
|
updatedAt: "5分钟前",
|
||||||
|
name: "空中草原观景道",
|
||||||
|
storeName: nil,
|
||||||
|
avatar: nil,
|
||||||
|
summary: nil,
|
||||||
|
detail: nil,
|
||||||
|
distanceText: "320米",
|
||||||
|
reporterName: "张三",
|
||||||
|
reporterPhone: "13333333333",
|
||||||
|
maskedReporterPhone: maskPhone("13333333333"),
|
||||||
|
reportContents: [
|
||||||
|
WildReportRadarReportContent(
|
||||||
|
time: "14:32:18",
|
||||||
|
content: "多人结伴揽客,疑似无资质摄影团队,携带专业器材在观景道附近活动。"
|
||||||
|
),
|
||||||
|
WildReportRadarReportContent(
|
||||||
|
time: "14:45:06",
|
||||||
|
content: "补充证据:对方仍在向游客推销拍照套餐,并拒绝出示相关资质。"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
images: [
|
||||||
|
"https://picsum.photos/seed/nalati-radar-1/640/480",
|
||||||
|
"https://picsum.photos/seed/nalati-radar-2/640/480",
|
||||||
|
"https://picsum.photos/seed/nalati-radar-3/640/480"
|
||||||
|
],
|
||||||
|
processingStarted: false,
|
||||||
|
completed: false,
|
||||||
|
record: nil,
|
||||||
|
processingTimeline: [],
|
||||||
|
completionPhoto: nil
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
clueID2,
|
||||||
|
"塔吾萨尼步道",
|
||||||
|
CLLocationCoordinate2D(latitude: 43.3652, longitude: 84.0385),
|
||||||
|
WildReportRadarActiveClue(
|
||||||
|
id: clueID2,
|
||||||
|
type: .red,
|
||||||
|
displayTitle: "线索 ID:\(clueID2)",
|
||||||
|
statusText: "疑似打野",
|
||||||
|
actionText: "导航到此",
|
||||||
|
nearestAbnormalText: nil,
|
||||||
|
distance: nil,
|
||||||
|
direction: nil,
|
||||||
|
updatedAt: "18分钟前",
|
||||||
|
name: "塔吾萨尼步道",
|
||||||
|
storeName: nil,
|
||||||
|
avatar: nil,
|
||||||
|
summary: nil,
|
||||||
|
detail: nil,
|
||||||
|
distanceText: "780米",
|
||||||
|
reporterName: "李四",
|
||||||
|
reporterPhone: "18674598210",
|
||||||
|
maskedReporterPhone: maskPhone("18674598210"),
|
||||||
|
reportContents: [
|
||||||
|
WildReportRadarReportContent(
|
||||||
|
time: "10:18:42",
|
||||||
|
content: "疑似二次转移,仍在塔吾萨尼步道附近揽客拍照。"
|
||||||
|
),
|
||||||
|
WildReportRadarReportContent(
|
||||||
|
time: "10:26:15",
|
||||||
|
content: "补充:对方穿黑色外套,携带三脚架,在步道入口反复搭讪游客。"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
images: [
|
||||||
|
"https://picsum.photos/seed/nalati-radar-4/640/480",
|
||||||
|
"https://picsum.photos/seed/nalati-radar-5/640/480"
|
||||||
|
],
|
||||||
|
processingStarted: false,
|
||||||
|
completed: false,
|
||||||
|
record: nil,
|
||||||
|
processingTimeline: [],
|
||||||
|
completionPhoto: nil
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
clueID3,
|
||||||
|
"河谷草原西南侧",
|
||||||
|
CLLocationCoordinate2D(latitude: 43.3546, longitude: 84.0136),
|
||||||
|
WildReportRadarActiveClue(
|
||||||
|
id: clueID3,
|
||||||
|
type: .red,
|
||||||
|
displayTitle: "线索 ID:\(clueID3)",
|
||||||
|
statusText: "疑似打野",
|
||||||
|
actionText: "导航到此",
|
||||||
|
nearestAbnormalText: nil,
|
||||||
|
distance: nil,
|
||||||
|
direction: nil,
|
||||||
|
updatedAt: "昨天17:45",
|
||||||
|
name: "河谷草原西南侧",
|
||||||
|
storeName: nil,
|
||||||
|
avatar: nil,
|
||||||
|
summary: nil,
|
||||||
|
detail: nil,
|
||||||
|
distanceText: "910米",
|
||||||
|
reporterName: "王五",
|
||||||
|
reporterPhone: "15938267120",
|
||||||
|
maskedReporterPhone: maskPhone("15938267120"),
|
||||||
|
reportContents: [
|
||||||
|
WildReportRadarReportContent(
|
||||||
|
time: "17:45:03",
|
||||||
|
content: "游客反馈有人在步道旁揽客拍照,行为较为可疑。"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
images: [
|
||||||
|
"https://picsum.photos/seed/nalati-radar-8/640/480"
|
||||||
|
],
|
||||||
|
processingStarted: false,
|
||||||
|
completed: false,
|
||||||
|
record: nil,
|
||||||
|
processingTimeline: [],
|
||||||
|
completionPhoto: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
let nearestText = nearestAbnormalText(
|
||||||
|
blueCoordinate: blueCoordinate,
|
||||||
|
redCoordinates: redSeeds.map(\.coordinate)
|
||||||
|
)
|
||||||
|
|
||||||
|
var markers: [WildReportMapMarker] = [
|
||||||
|
WildReportMapMarker(
|
||||||
|
id: "ME",
|
||||||
|
title: "河谷草原入口",
|
||||||
|
coordinate: blueCoordinate,
|
||||||
|
kind: .selfLocation,
|
||||||
|
detail: .activeClue(
|
||||||
|
WildReportRadarActiveClue(
|
||||||
|
id: "ME",
|
||||||
|
type: .blue,
|
||||||
|
displayTitle: "当前位置:河谷草原入口",
|
||||||
|
statusText: "当前位置",
|
||||||
|
actionText: "刷新定位",
|
||||||
|
nearestAbnormalText: nearestText,
|
||||||
|
distance: "0 米",
|
||||||
|
direction: "蓝色基准点",
|
||||||
|
updatedAt: "实时定位",
|
||||||
|
name: "河谷草原入口",
|
||||||
|
storeName: nil,
|
||||||
|
avatar: nil,
|
||||||
|
summary: "蓝色标记为当前位置,可对比周边摄影师风险点位距离。",
|
||||||
|
detail: "当前定位在河谷草原入口附近,建议优先关注 800 米内的异常点位。",
|
||||||
|
distanceText: nil,
|
||||||
|
reporterName: nil,
|
||||||
|
reporterPhone: nil,
|
||||||
|
maskedReporterPhone: nil,
|
||||||
|
reportContents: [],
|
||||||
|
images: [],
|
||||||
|
processingStarted: false,
|
||||||
|
completed: false,
|
||||||
|
record: nil,
|
||||||
|
processingTimeline: [],
|
||||||
|
completionPhoto: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
for seed in redSeeds {
|
||||||
|
let processingStarted = initialProcessingClueIDs.contains(seed.id)
|
||||||
|
let clue = enrichClue(seed.clue, processingStarted: processingStarted)
|
||||||
|
markers.append(
|
||||||
|
WildReportMapMarker(
|
||||||
|
id: seed.id,
|
||||||
|
title: seed.name,
|
||||||
|
coordinate: seed.coordinate,
|
||||||
|
kind: markerKind(for: clue),
|
||||||
|
detail: .activeClue(clue)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let greenSeeds: [(id: String, coordinate: CLLocationCoordinate2D, clue: WildReportRadarActiveClue)] = [
|
||||||
|
(
|
||||||
|
"PG20240618001",
|
||||||
|
CLLocationCoordinate2D(latitude: 43.4042, longitude: 84.0186),
|
||||||
|
WildReportRadarActiveClue(
|
||||||
|
id: "PG20240618001",
|
||||||
|
type: .green,
|
||||||
|
displayTitle: "认证摄影师",
|
||||||
|
statusText: "认证摄影师",
|
||||||
|
actionText: "导航到此",
|
||||||
|
nearestAbnormalText: nil,
|
||||||
|
distance: "260 米",
|
||||||
|
direction: "北侧点位",
|
||||||
|
updatedAt: "在线",
|
||||||
|
name: "阿依达娜",
|
||||||
|
storeName: "空中草原旅拍店",
|
||||||
|
avatar: "person.crop.circle.fill",
|
||||||
|
summary: "绿色标记为已认证摄影师,可与红色摄影师风险点位区分。",
|
||||||
|
detail: "当前在空中草原区域服务游客拍摄。",
|
||||||
|
distanceText: nil,
|
||||||
|
reporterName: nil,
|
||||||
|
reporterPhone: nil,
|
||||||
|
maskedReporterPhone: nil,
|
||||||
|
reportContents: [],
|
||||||
|
images: [],
|
||||||
|
processingStarted: false,
|
||||||
|
completed: false,
|
||||||
|
record: nil,
|
||||||
|
processingTimeline: [],
|
||||||
|
completionPhoto: nil
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"PG20240618002",
|
||||||
|
CLLocationCoordinate2D(latitude: 43.3985, longitude: 84.0408),
|
||||||
|
WildReportRadarActiveClue(
|
||||||
|
id: "PG20240618002",
|
||||||
|
type: .green,
|
||||||
|
displayTitle: "认证摄影师",
|
||||||
|
statusText: "认证摄影师",
|
||||||
|
actionText: "导航到此",
|
||||||
|
nearestAbnormalText: nil,
|
||||||
|
distance: "430 米",
|
||||||
|
direction: "东北方向",
|
||||||
|
updatedAt: "在线",
|
||||||
|
name: "波拉提",
|
||||||
|
storeName: "天界台摄影服务点",
|
||||||
|
avatar: "person.crop.circle.fill",
|
||||||
|
summary: "绿色标记为已认证摄影师,属于正常服务点位。",
|
||||||
|
detail: "当前在天界台附近接待预约游客。",
|
||||||
|
distanceText: nil,
|
||||||
|
reporterName: nil,
|
||||||
|
reporterPhone: nil,
|
||||||
|
maskedReporterPhone: nil,
|
||||||
|
reportContents: [],
|
||||||
|
images: [],
|
||||||
|
processingStarted: false,
|
||||||
|
completed: false,
|
||||||
|
record: nil,
|
||||||
|
processingTimeline: [],
|
||||||
|
completionPhoto: nil
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"PG20240618003",
|
||||||
|
CLLocationCoordinate2D(latitude: 43.3828, longitude: 84.0794),
|
||||||
|
WildReportRadarActiveClue(
|
||||||
|
id: "PG20240618003",
|
||||||
|
type: .green,
|
||||||
|
displayTitle: "认证摄影师",
|
||||||
|
statusText: "认证摄影师",
|
||||||
|
actionText: "导航到此",
|
||||||
|
nearestAbnormalText: nil,
|
||||||
|
distance: "620 米",
|
||||||
|
direction: "东侧点位",
|
||||||
|
updatedAt: "在线",
|
||||||
|
name: "米热丽",
|
||||||
|
storeName: "塔吾萨尼影像馆",
|
||||||
|
avatar: "person.crop.circle.fill",
|
||||||
|
summary: "绿色标记为已认证摄影师,和红色异常点位分开显示。",
|
||||||
|
detail: "当前在塔吾萨尼步道附近提供旅拍服务。",
|
||||||
|
distanceText: nil,
|
||||||
|
reporterName: nil,
|
||||||
|
reporterPhone: nil,
|
||||||
|
maskedReporterPhone: nil,
|
||||||
|
reportContents: [],
|
||||||
|
images: [],
|
||||||
|
processingStarted: false,
|
||||||
|
completed: false,
|
||||||
|
record: nil,
|
||||||
|
processingTimeline: [],
|
||||||
|
completionPhoto: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
for seed in greenSeeds {
|
||||||
|
markers.append(
|
||||||
|
WildReportMapMarker(
|
||||||
|
id: seed.id,
|
||||||
|
title: seed.clue.name ?? seed.id,
|
||||||
|
coordinate: seed.coordinate,
|
||||||
|
kind: .peerPhotographer,
|
||||||
|
detail: .activeClue(seed.clue)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return markers
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据全部标记计算地图可视区域。
|
||||||
|
static func region(for markers: [WildReportMapMarker]) -> MKCoordinateRegion {
|
||||||
|
guard let first = markers.first else {
|
||||||
|
return MKCoordinateRegion(
|
||||||
|
center: nalatiCenter,
|
||||||
|
span: MKCoordinateSpan(latitudeDelta: 0.025, longitudeDelta: 0.025)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var minLat = first.coordinate.latitude
|
||||||
|
var maxLat = first.coordinate.latitude
|
||||||
|
var minLon = first.coordinate.longitude
|
||||||
|
var maxLon = first.coordinate.longitude
|
||||||
|
|
||||||
|
for marker in markers {
|
||||||
|
minLat = min(minLat, marker.coordinate.latitude)
|
||||||
|
maxLat = max(maxLat, marker.coordinate.latitude)
|
||||||
|
minLon = min(minLon, marker.coordinate.longitude)
|
||||||
|
maxLon = max(maxLon, marker.coordinate.longitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
let center = CLLocationCoordinate2D(
|
||||||
|
latitude: (minLat + maxLat) / 2,
|
||||||
|
longitude: (minLon + maxLon) / 2
|
||||||
|
)
|
||||||
|
let span = MKCoordinateSpan(
|
||||||
|
latitudeDelta: max(0.022, (maxLat - minLat) * 1.8),
|
||||||
|
longitudeDelta: max(0.022, (maxLon - minLon) * 1.8)
|
||||||
|
)
|
||||||
|
return MKCoordinateRegion(center: center, span: span)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func processRecord(for clueID: String) -> WildReportRadarProcessRecord? {
|
||||||
|
switch clueID {
|
||||||
|
case clueID1:
|
||||||
|
return WildReportRadarProcessRecord(
|
||||||
|
handlerName: "王磊",
|
||||||
|
phone: "13812346721",
|
||||||
|
maskedPhone: maskPhone("13812346721"),
|
||||||
|
statusText: "待处理",
|
||||||
|
processResult: "待现场核查,请前往空中草原观景道确认摄影师线索。",
|
||||||
|
processedAt: nil,
|
||||||
|
processingAt: nil
|
||||||
|
)
|
||||||
|
case clueID2:
|
||||||
|
return WildReportRadarProcessRecord(
|
||||||
|
handlerName: "李娜",
|
||||||
|
phone: "18674598210",
|
||||||
|
maskedPhone: maskPhone("18674598210"),
|
||||||
|
statusText: "处理中",
|
||||||
|
processResult: "待现场核查,请前往塔吾萨尼步道确认摄影师线索。",
|
||||||
|
processedAt: nil,
|
||||||
|
processingAt: "2024-05-18 10:35"
|
||||||
|
)
|
||||||
|
case clueID3:
|
||||||
|
return WildReportRadarProcessRecord(
|
||||||
|
handlerName: "赵强",
|
||||||
|
phone: "15938267120",
|
||||||
|
maskedPhone: maskPhone("15938267120"),
|
||||||
|
statusText: "已处理",
|
||||||
|
processResult: "已到场核查并完成劝离,处理结果已同步景区公安。",
|
||||||
|
processedAt: "2024-05-17 18:26",
|
||||||
|
processingAt: "2024-05-17 17:45"
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildProcessingTimeline(
|
||||||
|
record: WildReportRadarProcessRecord,
|
||||||
|
completed: Bool
|
||||||
|
) -> [WildReportRadarProcessingTimelineItem] {
|
||||||
|
let startedAt = record.processingAt ?? "2024-05-18 10:35"
|
||||||
|
return [
|
||||||
|
WildReportRadarProcessingTimelineItem(
|
||||||
|
title: "处理时间",
|
||||||
|
time: startedAt,
|
||||||
|
status: "done"
|
||||||
|
),
|
||||||
|
WildReportRadarProcessingTimelineItem(
|
||||||
|
title: "处理完成时间",
|
||||||
|
time: completed ? (record.processedAt ?? "刚刚") : "待完成",
|
||||||
|
status: completed ? "done" : "current"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func enrichClue(
|
||||||
|
_ base: WildReportRadarActiveClue,
|
||||||
|
processingStarted: Bool,
|
||||||
|
completed: Bool = false,
|
||||||
|
completionPhoto: String? = nil
|
||||||
|
) -> WildReportRadarActiveClue {
|
||||||
|
let record = processRecord(for: base.id)
|
||||||
|
let isProcessing = processingStarted && !completed
|
||||||
|
let timeline = (isProcessing || completed) && record != nil
|
||||||
|
? buildProcessingTimeline(record: record!, completed: completed)
|
||||||
|
: []
|
||||||
|
|
||||||
|
return WildReportRadarActiveClue(
|
||||||
|
id: base.id,
|
||||||
|
type: base.type,
|
||||||
|
displayTitle: base.displayTitle,
|
||||||
|
statusText: completed
|
||||||
|
? "已处理"
|
||||||
|
: (isProcessing ? "正在处理中" : base.statusText),
|
||||||
|
actionText: base.actionText,
|
||||||
|
nearestAbnormalText: base.nearestAbnormalText,
|
||||||
|
distance: base.distance,
|
||||||
|
direction: base.direction,
|
||||||
|
updatedAt: completed ? (record?.processedAt ?? "刚刚") : base.updatedAt,
|
||||||
|
name: base.name,
|
||||||
|
storeName: base.storeName,
|
||||||
|
avatar: base.avatar,
|
||||||
|
summary: base.summary,
|
||||||
|
detail: base.detail,
|
||||||
|
distanceText: base.distanceText,
|
||||||
|
reporterName: base.reporterName,
|
||||||
|
reporterPhone: base.reporterPhone,
|
||||||
|
maskedReporterPhone: base.maskedReporterPhone,
|
||||||
|
reportContents: base.reportContents,
|
||||||
|
images: base.images,
|
||||||
|
processingStarted: processingStarted || completed,
|
||||||
|
completed: completed,
|
||||||
|
record: (isProcessing || completed) ? record : nil,
|
||||||
|
processingTimeline: timeline,
|
||||||
|
completionPhoto: completionPhoto
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func maskPhone(_ phone: String) -> String {
|
||||||
|
guard phone.count == 11, phone.allSatisfy(\.isNumber) else { return phone }
|
||||||
|
return "\(phone.prefix(3))****\(phone.suffix(4))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nearestAbnormalText(
|
||||||
|
blueCoordinate: CLLocationCoordinate2D,
|
||||||
|
redCoordinates: [CLLocationCoordinate2D]
|
||||||
|
) -> String {
|
||||||
|
guard !redCoordinates.isEmpty else { return "距离最近的异常点位0.00KM" }
|
||||||
|
let minKm = redCoordinates.map {
|
||||||
|
calcDistanceKm(
|
||||||
|
lat1: blueCoordinate.latitude,
|
||||||
|
lon1: blueCoordinate.longitude,
|
||||||
|
lat2: $0.latitude,
|
||||||
|
lon2: $0.longitude
|
||||||
|
)
|
||||||
|
}.min() ?? 0
|
||||||
|
return String(format: "距离最近的异常点位%.2fKM", minKm)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func calcDistanceKm(
|
||||||
|
lat1: Double,
|
||||||
|
lon1: Double,
|
||||||
|
lat2: Double,
|
||||||
|
lon2: Double
|
||||||
|
) -> Double {
|
||||||
|
let earthRadiusKm = 6371.0
|
||||||
|
let toRad = { (deg: Double) in deg * .pi / 180 }
|
||||||
|
let dLat = toRad(lat2 - lat1)
|
||||||
|
let dLon = toRad(lon2 - lon1)
|
||||||
|
let a = sin(dLat / 2) * sin(dLat / 2)
|
||||||
|
+ cos(toRad(lat1)) * cos(toRad(lat2)) * sin(dLon / 2) * sin(dLon / 2)
|
||||||
|
return earthRadiusKm * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,630 @@
|
|||||||
|
//
|
||||||
|
// WildPhotographerReportViewModels.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/7/1.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import CoreLocation
|
||||||
|
import MapKit
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 举报摄影师首页 ViewModel,持有 Mock 举报数据并供子页面共享。
|
||||||
|
@MainActor
|
||||||
|
final class WildPhotographerReportHomeViewModel: ObservableObject {
|
||||||
|
@Published var pageState: WildReportPageState = .normal
|
||||||
|
@Published private(set) var reports: [WildReportRecord] = []
|
||||||
|
@Published private(set) var riskPoints: [WildReportRiskPoint] = []
|
||||||
|
@Published private(set) var supplementaryEvidences: [String: [WildReportSupplementaryEvidence]] = [:]
|
||||||
|
|
||||||
|
private var nextSequence = 0
|
||||||
|
private var nextSupplementSequence = 0
|
||||||
|
|
||||||
|
init() {
|
||||||
|
resetDemoData()
|
||||||
|
}
|
||||||
|
|
||||||
|
var emptyTitle: String { "暂无举报服务" }
|
||||||
|
var emptyImage: String { "exclamationmark.shield" }
|
||||||
|
var emptyMessage: String { "当前景区暂未开放举报摄影师演示入口。" }
|
||||||
|
|
||||||
|
/// 恢复演示数据并回到正常状态。
|
||||||
|
func restoreDemoData() {
|
||||||
|
resetDemoData()
|
||||||
|
pageState = .normal
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟错误后重新加载。
|
||||||
|
func retryLoadAfterError() {
|
||||||
|
pageState = .loading
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 450_000_000)
|
||||||
|
pageState = .normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交举报并写入 Mock 列表,返回新记录。
|
||||||
|
@discardableResult
|
||||||
|
func submitReport(
|
||||||
|
reportType: WildReportType,
|
||||||
|
description: String,
|
||||||
|
location: String,
|
||||||
|
contact: String?,
|
||||||
|
imageCount: Int,
|
||||||
|
videoCount: Int
|
||||||
|
) -> WildReportRecord {
|
||||||
|
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let parsedContact = WildPhotographerReportMockStore.parseContact(contact)
|
||||||
|
let record = WildReportRecord(
|
||||||
|
id: nextReportID(),
|
||||||
|
title: "摄影师举报",
|
||||||
|
reportType: reportType,
|
||||||
|
status: .pending,
|
||||||
|
location: location,
|
||||||
|
submitTime: WildPhotographerReportMockStore.submitTimeFormatter.string(from: Date()),
|
||||||
|
description: trimmedDescription,
|
||||||
|
wechatID: parsedContact.wechat,
|
||||||
|
phoneNumber: parsedContact.phone,
|
||||||
|
imageCount: imageCount,
|
||||||
|
videoCount: videoCount,
|
||||||
|
handlerInfo: nil
|
||||||
|
)
|
||||||
|
reports.insert(record, at: 0)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取指定举报的补充证据列表。
|
||||||
|
func supplementaryEvidences(for reportID: String) -> [WildReportSupplementaryEvidence] {
|
||||||
|
supplementaryEvidences[reportID] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 追加一条补充证据记录。
|
||||||
|
func appendSupplementaryEvidence(
|
||||||
|
reportID: String,
|
||||||
|
text: String,
|
||||||
|
imageCount: Int,
|
||||||
|
videoCount: Int
|
||||||
|
) {
|
||||||
|
let item = WildReportSupplementaryEvidence(
|
||||||
|
id: nextSupplementID(),
|
||||||
|
submitTime: WildPhotographerReportMockStore.submitTimeFormatter.string(from: Date()),
|
||||||
|
text: text.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
imageCount: imageCount,
|
||||||
|
videoCount: videoCount
|
||||||
|
)
|
||||||
|
var items = supplementaryEvidences[reportID] ?? []
|
||||||
|
items.insert(item, at: 0)
|
||||||
|
supplementaryEvidences[reportID] = items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置为种子 Mock 数据。
|
||||||
|
func resetDemoData() {
|
||||||
|
reports = WildPhotographerReportMockStore.seedReports
|
||||||
|
riskPoints = WildPhotographerReportMockStore.seedRiskPoints
|
||||||
|
supplementaryEvidences = WildPhotographerReportMockStore.seedSupplementaryEvidences
|
||||||
|
nextSequence = WildPhotographerReportMockStore.maxSequence(from: WildPhotographerReportMockStore.seedReports) + 1
|
||||||
|
nextSupplementSequence = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nextSupplementID() -> String {
|
||||||
|
defer { nextSupplementSequence += 1 }
|
||||||
|
return String(format: "SE%03d", nextSupplementSequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nextReportID() -> String {
|
||||||
|
let datePart = WildPhotographerReportMockStore.reportDateFormatter.string(from: Date())
|
||||||
|
defer { nextSequence += 1 }
|
||||||
|
return String(format: "JB\(datePart)%03d", nextSequence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交举报页 ViewModel,负责表单校验和前台定位。
|
||||||
|
@MainActor
|
||||||
|
final class WildPhotographerReportSubmitViewModel: ObservableObject {
|
||||||
|
@Published var pageState: WildReportPageState = .normal
|
||||||
|
@Published var images: [WildReportAttachment]
|
||||||
|
@Published var videos: [WildReportAttachment]
|
||||||
|
@Published var paymentImages: [WildReportAttachment] = []
|
||||||
|
@Published var selectedReportType: WildReportType = .suspectedWild
|
||||||
|
@Published var description = ""
|
||||||
|
@Published var contact = ""
|
||||||
|
@Published var reportLocation = ""
|
||||||
|
@Published var isUpdatingLocation = false
|
||||||
|
@Published var locationConfirmed = false
|
||||||
|
@Published var submitResult: WildReportSubmitResult?
|
||||||
|
@Published var validationMessage: String?
|
||||||
|
|
||||||
|
private let homeViewModel: WildPhotographerReportHomeViewModel
|
||||||
|
private let locationProvider: ForegroundLocationProvider
|
||||||
|
private var hasRequestedInitialLocation = false
|
||||||
|
|
||||||
|
var locationTitleText: String {
|
||||||
|
if reportLocation.isEmpty || isUpdatingLocation {
|
||||||
|
return "正在获取当前位置..."
|
||||||
|
}
|
||||||
|
return "当前定位:\(reportLocation)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var locationSubtitleText: String {
|
||||||
|
if isUpdatingLocation {
|
||||||
|
return "正在更新定位,请稍候"
|
||||||
|
}
|
||||||
|
if locationConfirmed {
|
||||||
|
return "已获取最新定位,如不准确请点击更新定位"
|
||||||
|
}
|
||||||
|
return "如位置不准确,请点击更新定位"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||||
|
locationProvider: ForegroundLocationProvider? = nil
|
||||||
|
) {
|
||||||
|
self.homeViewModel = homeViewModel
|
||||||
|
self.locationProvider = locationProvider ?? ForegroundLocationProvider()
|
||||||
|
self.images = WildPhotographerReportMockStore.initialImages
|
||||||
|
self.videos = WildPhotographerReportMockStore.initialVideos
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次进入页面时自动请求定位。
|
||||||
|
func loadInitialLocationIfNeeded() {
|
||||||
|
guard !hasRequestedInitialLocation else { return }
|
||||||
|
hasRequestedInitialLocation = true
|
||||||
|
refreshCurrentLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 限制举报说明最多 500 字。
|
||||||
|
func updateDescription(_ newValue: String) {
|
||||||
|
description = newValue.count > 500 ? String(newValue.prefix(500)) : newValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用前台定位 Provider 刷新当前位置。
|
||||||
|
func refreshCurrentLocation() {
|
||||||
|
isUpdatingLocation = true
|
||||||
|
locationConfirmed = false
|
||||||
|
reportLocation = "正在获取当前位置..."
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await locationProvider.requestCurrentLocation()
|
||||||
|
applyResolvedLocation(result.address)
|
||||||
|
} catch {
|
||||||
|
applyResolvedLocation(WildPhotographerReportMockStore.fallbackLocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从空状态恢复演示数据。
|
||||||
|
func restoreDemoDataFromEmpty() {
|
||||||
|
pageState = .normal
|
||||||
|
refreshCurrentLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟加载失败后恢复。
|
||||||
|
func retryLoadAfterError() {
|
||||||
|
pageState = .loading
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 450_000_000)
|
||||||
|
pageState = .normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除提交结果,用于导航返回。
|
||||||
|
func clearSubmitResult() {
|
||||||
|
submitResult = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验表单并提交举报。
|
||||||
|
func submitReport() {
|
||||||
|
guard !images.isEmpty || !videos.isEmpty else {
|
||||||
|
validationMessage = "请至少上传一项现场证据。"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
validationMessage = "请填写举报说明。"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !isUpdatingLocation else {
|
||||||
|
validationMessage = "正在获取定位,请稍后再提交。"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else {
|
||||||
|
validationMessage = "请先更新举报位置后再提交举报。"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let record = homeViewModel.submitReport(
|
||||||
|
reportType: selectedReportType,
|
||||||
|
description: description,
|
||||||
|
location: reportLocation,
|
||||||
|
contact: contact.isEmpty ? nil : contact,
|
||||||
|
imageCount: images.count,
|
||||||
|
videoCount: videos.count
|
||||||
|
)
|
||||||
|
submitResult = WildReportSubmitResult(
|
||||||
|
record: record,
|
||||||
|
imageCount: images.count,
|
||||||
|
videoCount: videos.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置表单到初始状态。
|
||||||
|
func resetForm() {
|
||||||
|
images = WildPhotographerReportMockStore.initialImages
|
||||||
|
videos = WildPhotographerReportMockStore.initialVideos
|
||||||
|
paymentImages = []
|
||||||
|
selectedReportType = .suspectedWild
|
||||||
|
description = ""
|
||||||
|
contact = ""
|
||||||
|
reportLocation = ""
|
||||||
|
locationConfirmed = false
|
||||||
|
isUpdatingLocation = false
|
||||||
|
hasRequestedInitialLocation = false
|
||||||
|
submitResult = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyResolvedLocation(_ location: String) {
|
||||||
|
reportLocation = location
|
||||||
|
locationConfirmed = true
|
||||||
|
isUpdatingLocation = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报成功页 ViewModel。
|
||||||
|
@MainActor
|
||||||
|
final class WildPhotographerReportSuccessViewModel: ObservableObject {
|
||||||
|
let result: WildReportSubmitResult
|
||||||
|
|
||||||
|
let progressSteps = [
|
||||||
|
"已提交",
|
||||||
|
"现场核查中",
|
||||||
|
"处理结果反馈"
|
||||||
|
]
|
||||||
|
|
||||||
|
init(result: WildReportSubmitResult) {
|
||||||
|
self.result = result
|
||||||
|
}
|
||||||
|
|
||||||
|
var uploadSummary: String {
|
||||||
|
var parts: [String] = []
|
||||||
|
if result.imageCount > 0 { parts.append("\(result.imageCount)张图片") }
|
||||||
|
if result.videoCount > 0 { parts.append("\(result.videoCount)段视频") }
|
||||||
|
return parts.isEmpty ? "暂无" : parts.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 复制举报编号到剪贴板。
|
||||||
|
func copyReportID() -> String {
|
||||||
|
UIPasteboard.general.string = result.record.id
|
||||||
|
return result.record.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报详情页 ViewModel。
|
||||||
|
@MainActor
|
||||||
|
final class WildPhotographerReportDetailViewModel: ObservableObject {
|
||||||
|
let record: WildReportRecord
|
||||||
|
private let homeViewModel: WildPhotographerReportHomeViewModel?
|
||||||
|
|
||||||
|
@Published var actionMessage: String?
|
||||||
|
|
||||||
|
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel? = nil) {
|
||||||
|
self.record = record
|
||||||
|
self.homeViewModel = homeViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
var heroTitle: String {
|
||||||
|
record.title.isEmpty ? "摄影师举报" : record.title
|
||||||
|
}
|
||||||
|
|
||||||
|
var scenicAreaName: String {
|
||||||
|
WildReportLocationParser.split(record.location).scenic
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailAddress: String {
|
||||||
|
let detail = WildReportLocationParser.split(record.location).detail
|
||||||
|
return detail.contains("附近") ? detail : "\(detail)附近"
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayStatusText: String { record.status.rawValue }
|
||||||
|
var displayStatusColor: Color { record.status.displayColor }
|
||||||
|
var descriptionText: String { record.description }
|
||||||
|
var contactText: String { record.contactDisplayText }
|
||||||
|
|
||||||
|
var supplementaryEvidences: [WildReportSupplementaryEvidence] {
|
||||||
|
homeViewModel?.supplementaryEvidences(for: record.id) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageCountText: String { "\(record.imageCount)张" }
|
||||||
|
var videoCountText: String { record.videoCount > 0 ? "\(record.videoCount)段" : "0段" }
|
||||||
|
|
||||||
|
var reporterMediaSummary: String {
|
||||||
|
switch (record.imageCount, record.videoCount) {
|
||||||
|
case (0, 0): "0项"
|
||||||
|
case (_, 0): "\(record.imageCount)张"
|
||||||
|
case (0, _): "\(record.videoCount)段"
|
||||||
|
default: "\(record.imageCount)张 \(record.videoCount)段"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var reporterMediaItems: [WildReportReporterMediaItem] {
|
||||||
|
var items: [WildReportReporterMediaItem] = []
|
||||||
|
for index in 0..<record.imageCount { items.append(.image(index: index)) }
|
||||||
|
for index in 0..<record.videoCount { items.append(.video(index: index)) }
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
var progressSteps: [WildReportProgressStep] {
|
||||||
|
Self.buildProgressSteps(for: record)
|
||||||
|
}
|
||||||
|
|
||||||
|
var showHandlerInfo: Bool {
|
||||||
|
record.status == .processed && record.handlerInfo != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var handlerInfo: WildReportHandlerInfo? { record.handlerInfo }
|
||||||
|
|
||||||
|
/// 展示举报人材料预览提示。
|
||||||
|
func showReporterMediaPreview(_ item: WildReportReporterMediaItem) {
|
||||||
|
switch item {
|
||||||
|
case .image: showMediaPreview(kind: "图片")
|
||||||
|
case .video: showMediaPreview(kind: "视频")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 展示媒体预览提示。
|
||||||
|
func showMediaPreview(kind: String) {
|
||||||
|
actionMessage = "演示环境暂不支持预览\(kind),可在提交页查看上传内容。"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 展示位置预览提示。
|
||||||
|
func showLocationPreview() {
|
||||||
|
actionMessage = "举报位置:\(record.location)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成分享提示文案。
|
||||||
|
func shareReport() {
|
||||||
|
actionMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildProgressSteps(for record: WildReportRecord) -> [WildReportProgressStep] {
|
||||||
|
let baseDate = submitDate(from: record.submitTime) ?? Date()
|
||||||
|
let expectedFeedback = formatDate(baseDate.addingTimeInterval(8 * 3_600))
|
||||||
|
|
||||||
|
switch record.status {
|
||||||
|
case .pending, .processing:
|
||||||
|
return [
|
||||||
|
makeStep(id: "1", title: "已提交", date: baseDate, state: .completed),
|
||||||
|
makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), state: .completed),
|
||||||
|
makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .current),
|
||||||
|
makeStep(id: "4", title: "待结果反馈", date: nil, state: .pending, pendingText: "预计 \(expectedFeedback) 前")
|
||||||
|
]
|
||||||
|
case .processed:
|
||||||
|
return [
|
||||||
|
makeStep(id: "1", title: "已提交", date: baseDate, state: .completed),
|
||||||
|
makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), state: .completed),
|
||||||
|
makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .completed),
|
||||||
|
makeStep(id: "4", title: "已反馈核查结果", date: baseDate.addingTimeInterval(3 * 3_600), state: .completed)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeStep(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
date: Date?,
|
||||||
|
state: WildReportProgressStepState,
|
||||||
|
pendingText: String? = nil
|
||||||
|
) -> WildReportProgressStep {
|
||||||
|
WildReportProgressStep(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
timeText: pendingText ?? (date.map(formatDate) ?? ""),
|
||||||
|
state: state
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func submitDate(from text: String) -> Date? {
|
||||||
|
WildPhotographerReportMockStore.submitTimeFormatter.date(from: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func formatDate(_ date: Date) -> String {
|
||||||
|
WildPhotographerReportMockStore.submitTimeFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 我的举报列表 ViewModel。
|
||||||
|
@MainActor
|
||||||
|
final class WildPhotographerReportListViewModel: ObservableObject {
|
||||||
|
@Published var selectedFilter: WildReportListFilter = .all
|
||||||
|
@Published var shareMessage: String?
|
||||||
|
|
||||||
|
let homeViewModel: WildPhotographerReportHomeViewModel
|
||||||
|
|
||||||
|
init(homeViewModel: WildPhotographerReportHomeViewModel) {
|
||||||
|
self.homeViewModel = homeViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
var filteredReports: [WildReportRecord] {
|
||||||
|
let reports = homeViewModel.reports
|
||||||
|
switch selectedFilter {
|
||||||
|
case .all: return reports
|
||||||
|
case .pending: return reports.filter { $0.status == .pending }
|
||||||
|
case .processing: return reports.filter { $0.status == .processing }
|
||||||
|
case .processed: return reports.filter { $0.status == .processed }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var footerTip: String {
|
||||||
|
"温馨提示:为保障处理效率,请如实举报并提供有效证据"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换列表筛选标签。
|
||||||
|
func selectFilter(_ filter: WildReportListFilter) {
|
||||||
|
withAnimation(.easeInOut(duration: 0.22)) {
|
||||||
|
selectedFilter = filter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成分享提示文案。
|
||||||
|
func shareReport(_ record: WildReportRecord) {
|
||||||
|
shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 补充证据页 ViewModel。
|
||||||
|
@MainActor
|
||||||
|
final class WildReportSupplementEvidenceViewModel: ObservableObject {
|
||||||
|
@Published var text = ""
|
||||||
|
@Published var images: [WildReportAttachment] = []
|
||||||
|
@Published var videos: [WildReportAttachment] = []
|
||||||
|
@Published var validationMessage: String?
|
||||||
|
@Published private(set) var didSubmit = false
|
||||||
|
|
||||||
|
let reportID: String
|
||||||
|
private let homeViewModel: WildPhotographerReportHomeViewModel
|
||||||
|
|
||||||
|
init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) {
|
||||||
|
self.reportID = reportID
|
||||||
|
self.homeViewModel = homeViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 限制补充说明最多 500 字。
|
||||||
|
func updateText(_ newValue: String) {
|
||||||
|
text = newValue.count > 500 ? String(newValue.prefix(500)) : newValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加演示用补充图片。
|
||||||
|
func addImage() {
|
||||||
|
guard images.count < 9 else { return }
|
||||||
|
images.append(WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加演示用补充视频。
|
||||||
|
func addVideo() {
|
||||||
|
guard videos.count < 3 else { return }
|
||||||
|
videos.append(WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验并提交补充证据。
|
||||||
|
func submit() {
|
||||||
|
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedText.isEmpty || !images.isEmpty || !videos.isEmpty else {
|
||||||
|
validationMessage = "请至少填写补充说明或上传一项证据。"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
homeViewModel.appendSupplementaryEvidence(
|
||||||
|
reportID: reportID,
|
||||||
|
text: trimmedText,
|
||||||
|
imageCount: images.count,
|
||||||
|
videoCount: videos.count
|
||||||
|
)
|
||||||
|
didSubmit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 附近风险地图 ViewModel。
|
||||||
|
@MainActor
|
||||||
|
final class WildReportRiskMapViewModel: ObservableObject {
|
||||||
|
@Published var region: MKCoordinateRegion
|
||||||
|
@Published var markers: [WildReportMapMarker]
|
||||||
|
let scenicAreaName: String
|
||||||
|
|
||||||
|
@Published var selectedMarkerID: String?
|
||||||
|
@Published var previewMedia: WildReportMapMediaItem?
|
||||||
|
@Published var previewImageURL: WildReportPreviewImageURL?
|
||||||
|
@Published var toastMessage: String?
|
||||||
|
|
||||||
|
init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) {
|
||||||
|
let markers = WildPhotographerReportMockStore.buildMarkers(record: record, riskPoints: riskPoints)
|
||||||
|
self.markers = markers
|
||||||
|
self.region = WildPhotographerReportMockStore.region(for: markers)
|
||||||
|
self.scenicAreaName = "那拉提景区"
|
||||||
|
self.selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedMarker: WildReportMapMarker? {
|
||||||
|
guard let selectedMarkerID else { return nil }
|
||||||
|
return markers.first { $0.id == selectedMarkerID }
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedClue: WildReportRadarActiveClue? {
|
||||||
|
guard let marker = selectedMarker,
|
||||||
|
case .activeClue(let clue) = marker.detail else { return nil }
|
||||||
|
return clue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选中地图标记。
|
||||||
|
func selectMarker(_ marker: WildReportMapMarker) {
|
||||||
|
selectedMarkerID = marker.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 展示媒体预览层。
|
||||||
|
func showMediaPreview(_ media: WildReportMapMediaItem) {
|
||||||
|
previewMedia = media
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭媒体预览层。
|
||||||
|
func dismissMediaPreview() {
|
||||||
|
previewMedia = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 展示网络图片预览。
|
||||||
|
func showImagePreview(_ url: String) {
|
||||||
|
previewImageURL = WildReportPreviewImageURL(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭网络图片预览。
|
||||||
|
func dismissImagePreview() {
|
||||||
|
previewImageURL = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 导航到选中标记或刷新蓝点定位。
|
||||||
|
func navigateToSelectedMarker() {
|
||||||
|
guard let marker = selectedMarker,
|
||||||
|
case .activeClue(let clue) = marker.detail else { return }
|
||||||
|
|
||||||
|
if clue.type == .blue {
|
||||||
|
refreshLocation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: marker.coordinate))
|
||||||
|
mapItem.name = marker.title
|
||||||
|
mapItem.openInMaps(launchOptions: [
|
||||||
|
MKLaunchOptionsMapCenterKey: NSValue(mkCoordinate: marker.coordinate),
|
||||||
|
MKLaunchOptionsMapSpanKey: NSValue(mkCoordinateSpan: region.span)
|
||||||
|
])
|
||||||
|
showToast("已切换到该点位")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将地图居中到当前位置标记。
|
||||||
|
func refreshLocation() {
|
||||||
|
guard let blueMarker = markers.first(where: { $0.kind == .selfLocation }) else { return }
|
||||||
|
selectedMarkerID = blueMarker.id
|
||||||
|
withAnimation(.easeInOut(duration: 0.35)) {
|
||||||
|
region = MKCoordinateRegion(
|
||||||
|
center: blueMarker.coordinate,
|
||||||
|
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showToast("已定位到当前位置")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成线索分享提示。
|
||||||
|
func shareSelectedClue() {
|
||||||
|
guard let clue = selectedClue else { return }
|
||||||
|
let title = clue.type == .red ? "线索 ID:\(clue.id)" : clue.displayTitle
|
||||||
|
showToast("已生成\(title)的分享信息,可发送给他人查看。")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showToast(_ message: String) {
|
||||||
|
toastMessage = message
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除地图页 Toast。
|
||||||
|
func clearToast() {
|
||||||
|
toastMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
# WildPhotographerReport 模块业务逻辑
|
||||||
|
|
||||||
|
## 模块职责
|
||||||
|
|
||||||
|
WildPhotographerReport 模块负责首页 `photographer_report` 入口,为摄影师和景区运营提供「举报摄影师」能力。
|
||||||
|
|
||||||
|
本模块与 `LocationReport`(位置上报)无关:前者是用户举报疑似打野/未戴工牌摄影师,后者是摄影师 GPS 打卡。
|
||||||
|
|
||||||
|
当前阶段使用 Mock 数据完成业务闭环,不接入真实后端接口。
|
||||||
|
|
||||||
|
## 页面流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Home[举报摄影师首页] --> Submit[提交举报]
|
||||||
|
Home --> List[我的举报]
|
||||||
|
Home --> Map[附近风险地图]
|
||||||
|
Home --> Rules[举报规则]
|
||||||
|
Submit -->|提交成功| Success[举报成功]
|
||||||
|
Success --> Detail[举报详情]
|
||||||
|
List --> Detail
|
||||||
|
Detail --> Supplement[补充证据]
|
||||||
|
Detail --> Map
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心实体
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `WildReportType` | 举报类型:疑似打野 / 未戴工牌 / 其他 |
|
||||||
|
| `WildReportStatus` | 处理状态:待处理 / 处理中 / 已处理 |
|
||||||
|
| `WildReportRecord` | 举报记录 |
|
||||||
|
| `WildReportRiskPoint` | 附近风险点 |
|
||||||
|
| `WildReportSupplementaryEvidence` | 补充证据 |
|
||||||
|
|
||||||
|
## 数据来源
|
||||||
|
|
||||||
|
- 全部 Mock 数据由 `WildPhotographerReportMockStore` 提供种子数据。
|
||||||
|
- `WildPhotographerReportHomeViewModel` 持有共享举报列表,提交页、列表页、详情页通过依赖注入共享。
|
||||||
|
- 提交页定位使用 `ForegroundLocationProvider` 前台即时定位;失败时回退到 `fallbackLocation`。
|
||||||
|
|
||||||
|
## 提交规则
|
||||||
|
|
||||||
|
1. 图片或视频至少上传一项。
|
||||||
|
2. 举报说明必填,最多 500 字。
|
||||||
|
3. 举报位置必须已确认。
|
||||||
|
4. 举报类型默认「疑似打野」。
|
||||||
|
5. 提交成功后生成 `JB` + 日期 + 3 位序号,状态为「待处理」。
|
||||||
|
|
||||||
|
## 路由
|
||||||
|
|
||||||
|
- 权限 URI:`photographer_report`
|
||||||
|
- 首页路由:`HomeRoute.photographerReport`
|
||||||
|
- 入口不固定置顶,依赖权限下发或用户手动添加到常用应用(`HomeCommonMenuStore.androidHomeMenuURIs` 白名单已包含该 URI)。
|
||||||
|
|
||||||
|
## 与 LocationReport 的边界
|
||||||
|
|
||||||
|
| 模块 | URI | 用途 | 后端 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| WildPhotographerReport | `photographer_report` | 举报摄影师 | Mock |
|
||||||
|
| LocationReport | `location_report` | 位置上报 | 真实 API |
|
||||||
|
|
||||||
|
## 测试要求
|
||||||
|
|
||||||
|
- `WildPhotographerReportTests`:Home/Submit/List ViewModel 校验与提交成功路径。
|
||||||
|
- `HomeMenuRouterTests`:`photographer_report` 路由映射。
|
||||||
@ -12,6 +12,8 @@
|
|||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>随心瞰商家版</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
|
|||||||
@ -16,13 +16,14 @@ final class HomeAllFunctionsBuilderTests: XCTestCase {
|
|||||||
topLevelPermissions: [
|
topLevelPermissions: [
|
||||||
permission(name: "基本信息", uri: "basic_info"),
|
permission(name: "基本信息", uri: "basic_info"),
|
||||||
permission(name: "位置上报", uri: "location_report"),
|
permission(name: "位置上报", uri: "location_report"),
|
||||||
|
permission(name: "举报摄影师", uri: "photographer_report"),
|
||||||
permission(name: "相册管理", uri: "album_list"),
|
permission(name: "相册管理", uri: "album_list"),
|
||||||
permission(name: "我的钱包", uri: "wallet")
|
permission(name: "我的钱包", uri: "wallet")
|
||||||
],
|
],
|
||||||
commonURIs: []
|
commonURIs: []
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(snapshot.allFunctions.map(\.uri), ["location_report", "wallet"])
|
XCTAssertEqual(snapshot.allFunctions.map(\.uri), ["location_report", "photographer_report", "wallet"])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试保持 API 顶层顺序,不使用 preferredOrder 重排。
|
/// 测试保持 API 顶层顺序,不使用 preferredOrder 重排。
|
||||||
|
|||||||
@ -269,6 +269,22 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
XCTAssertEqual(uris, ["wallet", "message_center"])
|
XCTAssertEqual(uris, ["wallet", "message_center"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 测试 photographer_report 属于 Android menuList 白名单,且可加入常用应用。
|
||||||
|
func testPhotographerReportIsWhitelistedAndCanBeAddedToCommon() {
|
||||||
|
XCTAssertTrue(HomeCommonMenuStore.androidHomeMenuURIs.contains("photographer_report"))
|
||||||
|
|
||||||
|
let store = HomeCommonMenuStore(defaults: makeIsolatedDefaults())
|
||||||
|
let uris = store.add(
|
||||||
|
"photographer_report",
|
||||||
|
current: ["location_report"],
|
||||||
|
topLevelPermissionURIs: ["location_report", "photographer_report"],
|
||||||
|
roleCode: "photographer",
|
||||||
|
accountScope: "scenic_user_photographer_report"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(uris, ["location_report", "photographer_report"])
|
||||||
|
}
|
||||||
|
|
||||||
/// 测试非白名单顶层项占用前 4 名额时,默认常用不会用后续白名单项补位。
|
/// 测试非白名单顶层项占用前 4 名额时,默认常用不会用后续白名单项补位。
|
||||||
func testDefaultCommonDoesNotBackfillAfterNonCatalogSlots() {
|
func testDefaultCommonDoesNotBackfillAfterNonCatalogSlots() {
|
||||||
let uris = HomeCommonMenuStore.defaultCommonURIs(
|
let uris = HomeCommonMenuStore.defaultCommonURIs(
|
||||||
|
|||||||
@ -22,52 +22,59 @@ final class HomeMenuRouterTests: XCTestCase {
|
|||||||
|
|
||||||
/// 测试已迁移的首页路由会解析到真实目标。
|
/// 测试已迁移的首页路由会解析到真实目标。
|
||||||
func testMigratedRoutesResolveToDestinations() {
|
func testMigratedRoutesResolveToDestinations() {
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "space_settings", title: ""), .destination(.profileSpace))
|
let expectations: [(String, HomeMenuResolvedRoute)] = [
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicselection", title: ""), .destination(.scenicSelection))
|
("space_settings", .destination(.profileSpaceSettings)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "store", title: ""), .destination(.moreFunctions))
|
("scenicselection", .destination(.scenicSelection)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "wallet", title: ""), .destination(.wallet))
|
("store", .destination(.moreFunctions)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_collection", title: ""), .destination(.paymentCollection))
|
("wallet", .destination(.wallet)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_qr", title: ""), .destination(.paymentCollection))
|
("payment_collection", .destination(.paymentCollection)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_code", title: ""), .destination(.paymentCollection))
|
("payment_qr", .destination(.paymentCollection)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "permission_apply", title: ""), .destination(.permissionApply))
|
("payment_code", .destination(.paymentCollection)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "permission_apply_status", title: ""), .destination(.permissionApplyStatus))
|
("permission_apply", .destination(.permissionApply)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicapplication", title: ""), .destination(.scenicApplication))
|
("permission_apply_status", .destination(.permissionApplyStatus)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management", title: ""), .destination(.taskManagement))
|
("scenicapplication", .destination(.scenicApplication)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management_editor", title: ""), .destination(.taskManagement))
|
("task_management", .destination(.taskManagement)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_create", title: ""), .destination(.taskCreate))
|
("task_management_editor", .destination(.taskManagement)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "cloud_management", title: ""), .destination(.cloudStorage))
|
("task_create", .destination(.taskCreate)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "cloud_storage_transit", title: ""), .destination(.cloudStorageTransit))
|
("cloud_management", .destination(.cloudStorage)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "asset_management", title: ""), .destination(.materialLibrary))
|
("cloud_storage_transit", .destination(.cloudStorageTransit)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "material_upload", title: ""), .destination(.materialUpload))
|
("asset_management", .destination(.materialLibrary)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList))
|
("material_upload", .destination(.materialUpload)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_trailer", title: ""), .destination(.albumTrailer))
|
("album_list", .destination(.albumList)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary))
|
("album_trailer", .destination(.albumTrailer)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload))
|
("sample_management", .destination(.sampleLibrary)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList))
|
("sample_upload", .destination(.sampleUpload)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport))
|
("checkin_points", .destination(.punchPointList)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report_history", title: ""), .destination(.locationReportHistory))
|
("location_report", .destination(.locationReport)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm", title: ""), .destination(.projectManagement))
|
("location_report_history", .destination(.locationReportHistory)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "project_edit", title: ""), .destination(.projectManagement))
|
("photographer_report", .destination(.photographerReport)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement))
|
("pm", .destination(.projectManagement)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement))
|
("project_edit", .destination(.projectManagement)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite))
|
("pm_manager", .destination(.pmProjectManagement)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_invite", title: ""), .destination(.photographerInvite))
|
("schedule_management", .destination(.scheduleManagement)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord))
|
("registration_invitation", .destination(.photographerInvite)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_detail", title: ""), .destination(.depositOrders))
|
("photographer_invite", .destination(.photographerInvite)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order", title: ""), .destination(.depositOrders))
|
("invite_record", .destination(.inviteRecord)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_shooting_info", title: ""), .destination(.depositOrders))
|
("deposit_order_detail", .destination(.depositOrders)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "withdrawal_audit", title: ""), .destination(.withdrawalAudit))
|
("deposit_order", .destination(.depositOrders)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "cooperation_order", title: ""), .destination(.cooperationOrder))
|
("deposit_order_shooting_info", .destination(.depositOrders)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""), .destination(.scenicSettlement))
|
("withdrawal_audit", .destination(.withdrawalAudit)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""), .destination(.scenicSettlementReview))
|
("cooperation_order", .destination(.cooperationOrder)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter))
|
("scenic_settlement", .destination(.scenicSettlement)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement))
|
("scenic_settlement_review", .destination(.scenicSettlementReview)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
|
("message_center", .destination(.messageCenter)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement))
|
("/scenic-queue", .destination(.queueManagement)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum))
|
("queue_management", .destination(.queueManagement)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "travel_album", title: ""), .destination(.travelAlbumEntry))
|
("live_stream_management", .destination(.liveManagement)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "operating-area", title: ""), .destination(.operatingArea))
|
("live_album", .destination(.liveAlbum)),
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pilot_cert", title: ""), .destination(.pilotCertification))
|
("travel_album", .destination(.travelAlbumEntry)),
|
||||||
|
("operating-area", .destination(.operatingArea)),
|
||||||
|
("pilot_cert", .destination(.pilotCertification))
|
||||||
|
]
|
||||||
|
|
||||||
|
for (uri, expected) in expectations {
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: uri, title: ""), expected, "URI \(uri) should resolve to migrated destination")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试 Tab 路由仍集中在 HomeMenuRouter。
|
/// 测试 Tab 路由仍集中在 HomeMenuRouter。
|
||||||
@ -184,6 +191,7 @@ final class HomeMenuRouterTests: XCTestCase {
|
|||||||
XCTAssertTrue(uris.contains("sample_management"))
|
XCTAssertTrue(uris.contains("sample_management"))
|
||||||
XCTAssertTrue(uris.contains("checkin_points"))
|
XCTAssertTrue(uris.contains("checkin_points"))
|
||||||
XCTAssertTrue(uris.contains("location_report"))
|
XCTAssertTrue(uris.contains("location_report"))
|
||||||
|
XCTAssertTrue(uris.contains("photographer_report"))
|
||||||
XCTAssertTrue(uris.contains("pm"))
|
XCTAssertTrue(uris.contains("pm"))
|
||||||
XCTAssertTrue(uris.contains("pm_manager"))
|
XCTAssertTrue(uris.contains("pm_manager"))
|
||||||
XCTAssertTrue(uris.contains("schedule_management"))
|
XCTAssertTrue(uris.contains("schedule_management"))
|
||||||
@ -201,6 +209,7 @@ final class HomeMenuRouterTests: XCTestCase {
|
|||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport))
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_report", title: ""), .destination(.photographerReport))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm", title: ""), .destination(.projectManagement))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm", title: ""), .destination(.projectManagement))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement))
|
||||||
|
|||||||
@ -0,0 +1,141 @@
|
|||||||
|
//
|
||||||
|
// WildPhotographerReportTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/7/1.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 举报摄影师模块测试,覆盖 Mock 数据、提交校验和列表筛选。
|
||||||
|
@MainActor
|
||||||
|
final class WildPhotographerReportHomeViewModelTests: XCTestCase {
|
||||||
|
/// 测试种子数据包含风险点和举报记录。
|
||||||
|
func testSeedDataLoadsReportsAndRiskPoints() {
|
||||||
|
let viewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.reports.isEmpty)
|
||||||
|
XCTAssertFalse(viewModel.riskPoints.isEmpty)
|
||||||
|
XCTAssertEqual(viewModel.reports.count, WildPhotographerReportMockStore.seedReports.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试提交后生成 JB 前缀编号并插入列表首位。
|
||||||
|
func testSubmitReportGeneratesDynamicID() {
|
||||||
|
let viewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let beforeCount = viewModel.reports.count
|
||||||
|
|
||||||
|
let record = viewModel.submitReport(
|
||||||
|
reportType: .suspectedWild,
|
||||||
|
description: "测试举报说明",
|
||||||
|
location: "测试景区 · 测试点位",
|
||||||
|
contact: "13800138000",
|
||||||
|
imageCount: 1,
|
||||||
|
videoCount: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.reports.count, beforeCount + 1)
|
||||||
|
XCTAssertEqual(viewModel.reports.first?.id, record.id)
|
||||||
|
XCTAssertTrue(record.id.hasPrefix("JB"))
|
||||||
|
XCTAssertEqual(record.status, .pending)
|
||||||
|
XCTAssertEqual(record.reportType, .suspectedWild)
|
||||||
|
XCTAssertEqual(record.phoneNumber, "13800138000")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WildPhotographerReportSubmitViewModelTests: XCTestCase {
|
||||||
|
/// 测试无证据时提交会被拦截。
|
||||||
|
func testSubmitFailsWithoutEvidence() {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
viewModel.images = []
|
||||||
|
viewModel.videos = []
|
||||||
|
viewModel.description = "有说明"
|
||||||
|
viewModel.reportLocation = "测试位置"
|
||||||
|
viewModel.locationConfirmed = true
|
||||||
|
|
||||||
|
viewModel.submitReport()
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.validationMessage, "请至少上传一项现场证据。")
|
||||||
|
XCTAssertNil(viewModel.submitResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试无说明时提交会被拦截。
|
||||||
|
func testSubmitFailsWithoutDescription() {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
viewModel.description = " "
|
||||||
|
viewModel.reportLocation = "测试位置"
|
||||||
|
viewModel.locationConfirmed = true
|
||||||
|
|
||||||
|
viewModel.submitReport()
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.validationMessage, "请填写举报说明。")
|
||||||
|
XCTAssertNil(viewModel.submitResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试定位未确认时提交会被拦截。
|
||||||
|
func testSubmitFailsWithoutConfirmedLocation() {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
viewModel.description = "测试说明"
|
||||||
|
viewModel.locationConfirmed = false
|
||||||
|
|
||||||
|
viewModel.submitReport()
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.validationMessage, "请先更新举报位置后再提交举报。")
|
||||||
|
XCTAssertNil(viewModel.submitResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试定位更新中时提交会被拦截。
|
||||||
|
func testSubmitFailsWhileUpdatingLocation() {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
viewModel.description = "测试说明"
|
||||||
|
viewModel.isUpdatingLocation = true
|
||||||
|
viewModel.locationConfirmed = true
|
||||||
|
viewModel.reportLocation = "测试位置"
|
||||||
|
|
||||||
|
viewModel.submitReport()
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.validationMessage, "正在获取定位,请稍后再提交。")
|
||||||
|
XCTAssertNil(viewModel.submitResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试校验通过后会写入共享 Mock 列表。
|
||||||
|
func testSubmitSuccessCreatesRecord() {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
viewModel.description = "测试说明"
|
||||||
|
viewModel.reportLocation = "测试景区 · 测试点"
|
||||||
|
viewModel.locationConfirmed = true
|
||||||
|
|
||||||
|
viewModel.submitReport()
|
||||||
|
|
||||||
|
XCTAssertNil(viewModel.validationMessage)
|
||||||
|
XCTAssertNotNil(viewModel.submitResult)
|
||||||
|
XCTAssertEqual(homeViewModel.reports.first?.id, viewModel.submitResult?.record.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WildPhotographerReportListViewModelTests: XCTestCase {
|
||||||
|
/// 测试列表筛选只返回对应状态记录。
|
||||||
|
func testFilterReturnsMatchingStatus() {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
|
||||||
|
|
||||||
|
viewModel.selectFilter(.pending)
|
||||||
|
XCTAssertTrue(viewModel.filteredReports.allSatisfy { $0.status == .pending })
|
||||||
|
|
||||||
|
viewModel.selectFilter(.processing)
|
||||||
|
XCTAssertTrue(viewModel.filteredReports.allSatisfy { $0.status == .processing })
|
||||||
|
|
||||||
|
viewModel.selectFilter(.processed)
|
||||||
|
XCTAssertTrue(viewModel.filteredReports.allSatisfy { $0.status == .processed })
|
||||||
|
|
||||||
|
viewModel.selectFilter(.all)
|
||||||
|
XCTAssertEqual(viewModel.filteredReports.count, homeViewModel.reports.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -56,6 +56,7 @@ enum UITestHomeMenuCatalog {
|
|||||||
Entry(menuTitle: "项目管理", expectation: .navigationTitle("项目管理")),
|
Entry(menuTitle: "项目管理", expectation: .navigationTitle("项目管理")),
|
||||||
Entry(menuTitle: "店铺项目管理", expectation: .navigationTitle("店铺项目管理")),
|
Entry(menuTitle: "店铺项目管理", expectation: .navigationTitle("店铺项目管理")),
|
||||||
Entry(menuTitle: "位置上报", expectation: .navigationTitle("位置上报")),
|
Entry(menuTitle: "位置上报", expectation: .navigationTitle("位置上报")),
|
||||||
|
Entry(menuTitle: "举报摄影师", expectation: .navigationTitle("举报摄影师")),
|
||||||
Entry(menuTitle: "注册邀请", expectation: .navigationTitle("摄影师邀请")),
|
Entry(menuTitle: "注册邀请", expectation: .navigationTitle("摄影师邀请")),
|
||||||
Entry(menuTitle: "邀请记录", expectation: .navigationTitle("邀请记录")),
|
Entry(menuTitle: "邀请记录", expectation: .navigationTitle("邀请记录")),
|
||||||
Entry(menuTitle: "店铺管理", expectation: .staticText("全部功能")),
|
Entry(menuTitle: "店铺管理", expectation: .staticText("全部功能")),
|
||||||
|
|||||||
@ -111,6 +111,7 @@
|
|||||||
| 完成 | 景区结算 | 景区结算申请、金额/备注、多景区提交和结算审核记录已接入。 | 审核记录复用景区申请/权限申请记录,不新增后端未体现的详情审批操作。 |
|
| 完成 | 景区结算 | 景区结算申请、金额/备注、多景区提交和结算审核记录已接入。 | 审核记录复用景区申请/权限申请记录,不新增后端未体现的详情审批操作。 |
|
||||||
| 完成 | 运营区域 | `operating-area` 已接真实页面;按店铺/景区管理员模式加载围栏,解析 `business_map_area` 并只读展示地图或模拟器坐标摘要。 | 本轮不做区域新增、编辑、删除或绘制保存。 |
|
| 完成 | 运营区域 | `operating-area` 已接真实页面;按店铺/景区管理员模式加载围栏,解析 `business_map_area` 并只读展示地图或模拟器坐标摘要。 | 本轮不做区域新增、编辑、删除或绘制保存。 |
|
||||||
| 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 |
|
| 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 |
|
||||||
|
| 完成 | 举报摄影师 | `photographer_report` 已接真实页面;支持提交举报、我的举报、详情、补充证据、附近风险地图;当前为 Mock 数据闭环。 | 后续接入真实 API 时保持页面字段和交互不变。 |
|
||||||
| 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 |
|
| 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 |
|
||||||
| 完成 | 飞手认证 | `pilot_cert` 已接真实页面;支持实名状态、认证详情、验证码、证件图 OSS 上传、首次提交和驳回编辑。 | 不包含 DJI/飞控 SDK 或真实飞行控制。 |
|
| 完成 | 飞手认证 | `pilot_cert` 已接真实页面;支持实名状态、认证详情、验证码、证件图 OSS 上传、首次提交和驳回编辑。 | 不包含 DJI/飞控 SDK 或真实飞行控制。 |
|
||||||
| 完成 | 合作订单 | `cooperation_order` 已接真实页面;双 Tab 列表(获客线索/获客订单)、合作获客员、扫码绑定、主 Tab 扫码识别 sale_user_bind。 | 与订单模块 orderType 11/14 订单来源联动。 |
|
| 完成 | 合作订单 | `cooperation_order` 已接真实页面;双 Tab 列表(获客线索/获客订单)、合作获客员、扫码绑定、主 Tab 扫码识别 sale_user_bind。 | 与订单模块 orderType 11/14 订单来源联动。 |
|
||||||
|
|||||||
Reference in New Issue
Block a user