From fcb86d43f521d1ef57dccde740192dc82a143b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 1 Jul 2026 16:51:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=BE=E6=8A=A5=E6=91=84?= =?UTF-8?q?=E5=BD=B1=E5=B8=88=E6=A8=A1=E5=9D=97=E5=B9=B6=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E8=B7=AF=E7=94=B1=EF=BC=8C=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=90=8D=E6=94=B9=E4=B8=BA=E9=9A=8F=E5=BF=83?= =?UTF-8?q?=E7=9E=B0=E5=95=86=E5=AE=B6=E7=89=88=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- suixinkan/Core/Design/AppDesign.swift | 4 + suixinkan/Features/Home/AllFunctions.md | 4 +- suixinkan/Features/Home/Home.md | 2 + .../Features/Home/Models/HomeMenuItem.swift | 1 + .../Home/Routing/HomeMenuRouter.swift | 6 + .../Home/Services/HomeCommonMenuStore.swift | 1 + .../Features/Home/Views/HomeIconCatalog.swift | 2 + .../Home/Views/HomeSupportViews.swift | 2 + .../Models/WildPhotographerReportModels.swift | 349 ++++++ .../WildPhotographerReportMockStore.swift | 717 +++++++++++++ .../WildPhotographerReportViewModels.swift | 630 +++++++++++ .../WildPhotographerReportDetailViews.swift | 992 ++++++++++++++++++ .../WildPhotographerReportHomeViews.swift | 451 ++++++++ .../WildPhotographerReportListViews.swift | 361 +++++++ .../WildPhotographerReportMapViews.swift | 803 ++++++++++++++ .../WildPhotographerReportSharedViews.swift | 91 ++ .../WildPhotographerReportSubmitViews.swift | 816 ++++++++++++++ .../WildPhotographerReport.md | 66 ++ suixinkan/Info.plist | 2 + .../HomeAllFunctionsBuilderTests.swift | 3 +- suixinkanTests/HomeCommonMenuStoreTests.swift | 16 + suixinkanTests/HomeMenuRouterTests.swift | 101 +- .../WildPhotographerReportTests.swift | 141 +++ .../Support/UITestHomeMenuCatalog.swift | 1 + 功能同步Checklist.md | 1 + 25 files changed, 5515 insertions(+), 48 deletions(-) create mode 100644 suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift create mode 100644 suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift create mode 100644 suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift create mode 100644 suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportDetailViews.swift create mode 100644 suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportHomeViews.swift create mode 100644 suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportListViews.swift create mode 100644 suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportMapViews.swift create mode 100644 suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportSharedViews.swift create mode 100644 suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportSubmitViews.swift create mode 100644 suixinkan/Features/WildPhotographerReport/WildPhotographerReport.md create mode 100644 suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift diff --git a/suixinkan/Core/Design/AppDesign.swift b/suixinkan/Core/Design/AppDesign.swift index 95fa917..0664cc8 100644 --- a/suixinkan/Core/Design/AppDesign.swift +++ b/suixinkan/Core/Design/AppDesign.swift @@ -16,6 +16,10 @@ enum AppDesign { static let placeholder = Color(hex: 0xA8B2C1) static let success = Color(hex: 0x14964A) 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) diff --git a/suixinkan/Features/Home/AllFunctions.md b/suixinkan/Features/Home/AllFunctions.md index 7decb26..8730e53 100644 --- a/suixinkan/Features/Home/AllFunctions.md +++ b/suixinkan/Features/Home/AllFunctions.md @@ -80,7 +80,9 @@ allFunctions = 顶层权限 .映射为 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` - `location_info`、`scan_qr`、`payment_qr` diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index 53c24cf..be9e59b 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -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` 接管。 后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。 + +`photographer_report` 已由 `Features/WildPhotographerReport` 接管。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index 7f03747..d6be001 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -55,6 +55,7 @@ enum HomeRoute: Hashable { case punchPointQR(id: Int, title: String, qrURL: String) case locationReport case locationReportHistory + case photographerReport case depositOrders case withdrawalAudit case cooperationOrder diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index 9fc2606..156ddd8 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -46,6 +46,7 @@ enum HomeMenuRouter { "pm_manager": "店铺项目管理", "project_edit": "项目管理", "location_report": "位置上报", + "photographer_report": "举报摄影师", "registration_invitation": "注册邀请", "store": "店铺管理", "fly": "飞行管理", @@ -131,6 +132,8 @@ enum HomeMenuRouter { return .destination(.locationReport) case "location_report_history": return .destination(.locationReportHistory) + case "photographer_report": + return .destination(.photographerReport) case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info": return .destination(.depositOrders) case "withdrawal_audit": @@ -216,6 +219,7 @@ enum HomeMenuRouter { "pm_manager", "project_edit", "location_report", + "photographer_report", "registration_invitation", "photographer_invite", "invite_record", @@ -294,6 +298,8 @@ enum HomeMenuRouter { return "注册邀请" case "location_report": return "位置上报" + case "photographer_report": + return "举报摄影师" case "pm", "project_edit": return "项目管理" case "pm_manager": diff --git a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift index dfdc4c7..4c4db78 100644 --- a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift +++ b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift @@ -30,6 +30,7 @@ struct HomeCommonMenuStore { "travel_album", "pm", "location_report", + "photographer_report", "registration_invitation", "store", "fly", diff --git a/suixinkan/Features/Home/Views/HomeIconCatalog.swift b/suixinkan/Features/Home/Views/HomeIconCatalog.swift index fe35281..1eebf90 100644 --- a/suixinkan/Features/Home/Views/HomeIconCatalog.swift +++ b/suixinkan/Features/Home/Views/HomeIconCatalog.swift @@ -54,6 +54,8 @@ enum HomeIconCatalog { "circle.grid.2x2.fill" case "location_report", "location_report_history": "mappin.circle.fill" + case "photographer_report": + "shield.fill" case "registration_invitation", "photographer_invite": "envelope.open.fill" case "store": diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index 6653fc9..3b1e397 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -86,6 +86,8 @@ extension HomeRoute { LocationReportView() case .locationReportHistory: LocationReportHistoryView() + case .photographerReport: + WildPhotographerReportHomeView() case .depositOrders: DepositOrderEntryView() case .withdrawalAudit: diff --git a/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift new file mode 100644 index 0000000..beaa9eb --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift @@ -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 + } +} diff --git a/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift b/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift new file mode 100644 index 0000000..f15c4db --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift @@ -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 = [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)) + } +} diff --git a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift new file mode 100644 index 0000000..6dd355f --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift @@ -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.. [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 + } +} diff --git a/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportDetailViews.swift b/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportDetailViews.swift new file mode 100644 index 0000000..4edfc46 --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportDetailViews.swift @@ -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 + } +} + diff --git a/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportHomeViews.swift b/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportHomeViews.swift new file mode 100644 index 0000000..9f92d59 --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportHomeViews.swift @@ -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) + ) + } +} + diff --git a/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportListViews.swift b/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportListViews.swift new file mode 100644 index 0000000..b013790 --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportListViews.swift @@ -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(@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) + } + } +} + diff --git a/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportMapViews.swift b/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportMapViews.swift new file mode 100644 index 0000000..6d26ae7 --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportMapViews.swift @@ -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 + ) + } +} diff --git a/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportSharedViews.swift b/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportSharedViews.swift new file mode 100644 index 0000000..12333f5 --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportSharedViews.swift @@ -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 diff --git a/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportSubmitViews.swift b/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportSubmitViews.swift new file mode 100644 index 0000000..15ab3e6 --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/Views/WildPhotographerReportSubmitViews.swift @@ -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( + 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.. 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 + } +} + diff --git a/suixinkan/Features/WildPhotographerReport/WildPhotographerReport.md b/suixinkan/Features/WildPhotographerReport/WildPhotographerReport.md new file mode 100644 index 0000000..9c725ac --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/WildPhotographerReport.md @@ -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` 路由映射。 diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist index 6d6d154..ab27047 100644 --- a/suixinkan/Info.plist +++ b/suixinkan/Info.plist @@ -12,6 +12,8 @@ 6.0 CFBundleName $(PRODUCT_NAME) + CFBundleDisplayName + 随心瞰商家版 CFBundlePackageType APPL CFBundleShortVersionString diff --git a/suixinkanTests/HomeAllFunctionsBuilderTests.swift b/suixinkanTests/HomeAllFunctionsBuilderTests.swift index ab5b275..2306bb8 100644 --- a/suixinkanTests/HomeAllFunctionsBuilderTests.swift +++ b/suixinkanTests/HomeAllFunctionsBuilderTests.swift @@ -16,13 +16,14 @@ final class HomeAllFunctionsBuilderTests: XCTestCase { topLevelPermissions: [ permission(name: "基本信息", uri: "basic_info"), permission(name: "位置上报", uri: "location_report"), + permission(name: "举报摄影师", uri: "photographer_report"), permission(name: "相册管理", uri: "album_list"), permission(name: "我的钱包", uri: "wallet") ], commonURIs: [] ) - XCTAssertEqual(snapshot.allFunctions.map(\.uri), ["location_report", "wallet"]) + XCTAssertEqual(snapshot.allFunctions.map(\.uri), ["location_report", "photographer_report", "wallet"]) } /// 测试保持 API 顶层顺序,不使用 preferredOrder 重排。 diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift index 7b79159..6bd4262 100644 --- a/suixinkanTests/HomeCommonMenuStoreTests.swift +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -269,6 +269,22 @@ final class HomeCommonMenuStoreTests: XCTestCase { 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 名额时,默认常用不会用后续白名单项补位。 func testDefaultCommonDoesNotBackfillAfterNonCatalogSlots() { let uris = HomeCommonMenuStore.defaultCommonURIs( diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index 525831c..d6208a5 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -22,52 +22,59 @@ final class HomeMenuRouterTests: XCTestCase { /// 测试已迁移的首页路由会解析到真实目标。 func testMigratedRoutesResolveToDestinations() { - XCTAssertEqual(HomeMenuRouter.resolve(uri: "space_settings", title: ""), .destination(.profileSpace)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicselection", title: ""), .destination(.scenicSelection)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "store", title: ""), .destination(.moreFunctions)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "wallet", title: ""), .destination(.wallet)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_collection", title: ""), .destination(.paymentCollection)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_qr", title: ""), .destination(.paymentCollection)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_code", title: ""), .destination(.paymentCollection)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "permission_apply", title: ""), .destination(.permissionApply)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "permission_apply_status", title: ""), .destination(.permissionApplyStatus)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicapplication", title: ""), .destination(.scenicApplication)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management", title: ""), .destination(.taskManagement)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management_editor", title: ""), .destination(.taskManagement)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_create", title: ""), .destination(.taskCreate)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "cloud_management", title: ""), .destination(.cloudStorage)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "cloud_storage_transit", title: ""), .destination(.cloudStorageTransit)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "asset_management", title: ""), .destination(.materialLibrary)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "material_upload", title: ""), .destination(.materialUpload)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_trailer", title: ""), .destination(.albumTrailer)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report_history", title: ""), .destination(.locationReportHistory)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm", title: ""), .destination(.projectManagement)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "project_edit", title: ""), .destination(.projectManagement)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_invite", title: ""), .destination(.photographerInvite)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_detail", title: ""), .destination(.depositOrders)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order", title: ""), .destination(.depositOrders)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_shooting_info", title: ""), .destination(.depositOrders)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "withdrawal_audit", title: ""), .destination(.withdrawalAudit)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "cooperation_order", title: ""), .destination(.cooperationOrder)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""), .destination(.scenicSettlement)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""), .destination(.scenicSettlementReview)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "travel_album", title: ""), .destination(.travelAlbumEntry)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "operating-area", title: ""), .destination(.operatingArea)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "pilot_cert", title: ""), .destination(.pilotCertification)) + let expectations: [(String, HomeMenuResolvedRoute)] = [ + ("space_settings", .destination(.profileSpaceSettings)), + ("scenicselection", .destination(.scenicSelection)), + ("store", .destination(.moreFunctions)), + ("wallet", .destination(.wallet)), + ("payment_collection", .destination(.paymentCollection)), + ("payment_qr", .destination(.paymentCollection)), + ("payment_code", .destination(.paymentCollection)), + ("permission_apply", .destination(.permissionApply)), + ("permission_apply_status", .destination(.permissionApplyStatus)), + ("scenicapplication", .destination(.scenicApplication)), + ("task_management", .destination(.taskManagement)), + ("task_management_editor", .destination(.taskManagement)), + ("task_create", .destination(.taskCreate)), + ("cloud_management", .destination(.cloudStorage)), + ("cloud_storage_transit", .destination(.cloudStorageTransit)), + ("asset_management", .destination(.materialLibrary)), + ("material_upload", .destination(.materialUpload)), + ("album_list", .destination(.albumList)), + ("album_trailer", .destination(.albumTrailer)), + ("sample_management", .destination(.sampleLibrary)), + ("sample_upload", .destination(.sampleUpload)), + ("checkin_points", .destination(.punchPointList)), + ("location_report", .destination(.locationReport)), + ("location_report_history", .destination(.locationReportHistory)), + ("photographer_report", .destination(.photographerReport)), + ("pm", .destination(.projectManagement)), + ("project_edit", .destination(.projectManagement)), + ("pm_manager", .destination(.pmProjectManagement)), + ("schedule_management", .destination(.scheduleManagement)), + ("registration_invitation", .destination(.photographerInvite)), + ("photographer_invite", .destination(.photographerInvite)), + ("invite_record", .destination(.inviteRecord)), + ("deposit_order_detail", .destination(.depositOrders)), + ("deposit_order", .destination(.depositOrders)), + ("deposit_order_shooting_info", .destination(.depositOrders)), + ("withdrawal_audit", .destination(.withdrawalAudit)), + ("cooperation_order", .destination(.cooperationOrder)), + ("scenic_settlement", .destination(.scenicSettlement)), + ("scenic_settlement_review", .destination(.scenicSettlementReview)), + ("message_center", .destination(.messageCenter)), + ("/scenic-queue", .destination(.queueManagement)), + ("queue_management", .destination(.queueManagement)), + ("live_stream_management", .destination(.liveManagement)), + ("live_album", .destination(.liveAlbum)), + ("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。 @@ -184,6 +191,7 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertTrue(uris.contains("sample_management")) XCTAssertTrue(uris.contains("checkin_points")) XCTAssertTrue(uris.contains("location_report")) + XCTAssertTrue(uris.contains("photographer_report")) XCTAssertTrue(uris.contains("pm")) XCTAssertTrue(uris.contains("pm_manager")) 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: "checkin_points", title: ""), .destination(.punchPointList)) 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_manager", title: ""), .destination(.pmProjectManagement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement)) diff --git a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift new file mode 100644 index 0000000..d3b1e81 --- /dev/null +++ b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift @@ -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) + } +} diff --git a/suixinkanUITests/Support/UITestHomeMenuCatalog.swift b/suixinkanUITests/Support/UITestHomeMenuCatalog.swift index 6b80791..edf1bab 100644 --- a/suixinkanUITests/Support/UITestHomeMenuCatalog.swift +++ b/suixinkanUITests/Support/UITestHomeMenuCatalog.swift @@ -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: .staticText("全部功能")), diff --git a/功能同步Checklist.md b/功能同步Checklist.md index deab964..0fb273a 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -111,6 +111,7 @@ | 完成 | 景区结算 | 景区结算申请、金额/备注、多景区提交和结算审核记录已接入。 | 审核记录复用景区申请/权限申请记录,不新增后端未体现的详情审批操作。 | | 完成 | 运营区域 | `operating-area` 已接真实页面;按店铺/景区管理员模式加载围栏,解析 `business_map_area` 并只读展示地图或模拟器坐标摘要。 | 本轮不做区域新增、编辑、删除或绘制保存。 | | 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 | +| 完成 | 举报摄影师 | `photographer_report` 已接真实页面;支持提交举报、我的举报、详情、补充证据、附近风险地图;当前为 Mock 数据闭环。 | 后续接入真实 API 时保持页面字段和交互不变。 | | 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 | | 完成 | 飞手认证 | `pilot_cert` 已接真实页面;支持实名状态、认证详情、验证码、证件图 OSS 上传、首次提交和驳回编辑。 | 不包含 DJI/飞控 SDK 或真实飞行控制。 | | 完成 | 合作订单 | `cooperation_order` 已接真实页面;双 Tab 列表(获客线索/获客订单)、合作获客员、扫码绑定、主 Tab 扫码识别 sale_user_bind。 | 与订单模块 orderType 11/14 订单来源联动。 |