From 8a65d3c104c708e463c6c633bd119b5d14ddc34b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 8 Jul 2026 12:06:13 +0800 Subject: [PATCH] feat: add wild photographer report mock flow --- .../Home/Services/HomeMenuCatalog.swift | 1 + .../Home/Services/HomeRouteHandler.swift | 2 + .../Models/WildPhotographerReportModels.swift | 349 +++++++++ .../WildPhotographerReportMockStore.swift | 604 +++++++++++++++ .../WildPhotographerReportViewModels.swift | 685 ++++++++++++++++++ .../Views/WildReportCommonViews.swift | 209 ++++++ ...PhotographerReportHomeViewController.swift | 489 +++++++++++++ ...PhotographerReportListViewController.swift | 356 +++++++++ ...otographerReportSubmitViewController.swift | 330 +++++++++ .../WildReportRiskMapViewController.swift | 237 ++++++ ...portSupplementEvidenceViewController.swift | 128 ++++ .../AllFunctionsViewModelTests.swift | 23 + suixinkanTests/HomeCommonMenuStoreTests.swift | 7 + .../WildPhotographerReportTests.swift | 122 ++++ 14 files changed, 3542 insertions(+) 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/UI/WildPhotographerReport/Views/WildReportCommonViews.swift create mode 100644 suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift create mode 100644 suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift create mode 100644 suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift create mode 100644 suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift create mode 100644 suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift create mode 100644 suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift diff --git a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift index 0d28ac8..556f83c 100644 --- a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift +++ b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift @@ -28,6 +28,7 @@ enum HomeMenuCatalog { HomeMenuItem(uri: "pm", title: "项目管理", iconName: "folder"), HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "folder"), HomeMenuItem(uri: "location_report", title: "位置上报", iconName: "location"), + HomeMenuItem(uri: "report_photographer", title: "举报摄影师", iconName: "exclamationmark.shield"), HomeMenuItem(uri: "registration_invitation", title: "注册邀请", iconName: "person.badge.plus"), HomeMenuItem(uri: "photographer_invite", title: "邀请摄影师", iconName: "person.badge.plus"), HomeMenuItem(uri: "invite_record", title: "邀请记录", iconName: "calendar.badge.clock"), diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift index 7f7e2a9..53b90b6 100644 --- a/suixinkan/Features/Home/Services/HomeRouteHandler.swift +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -50,6 +50,8 @@ enum HomeRouteHandler { viewController.navigationController?.pushViewController(CooperationOrderListViewController(), animated: true) case "location_report": viewController.navigationController?.pushViewController(LocationReportViewController(), animated: true) + case "report_photographer": + viewController.navigationController?.pushViewController(WildPhotographerReportHomeViewController(), animated: true) case "registration_invitation", "photographer_invite": viewController.navigationController?.pushViewController(PhotographerInviteViewController(), animated: true) case "invite_record": diff --git a/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift new file mode 100644 index 0000000..c32f037 --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift @@ -0,0 +1,349 @@ +// +// WildPhotographerReportModels.swift +// suixinkan +// + +import CoreLocation +import Foundation +import MapKit +import UIKit + +/// 举报摄影师页面状态,用于演示加载、空态和异常态。 +enum WildReportPageState: String, CaseIterable, Hashable { + case normal + case loading + case empty + case error + + var title: String { + switch self { + case .normal: return "正常" + case .loading: return "加载" + case .empty: return "空状态" + case .error: return "异常" + } + } +} + +/// 举报附件类型,区分现场图片、视频和支付截图。 +enum WildReportAttachmentKind: Hashable { + case image + case video + case payment +} + +/// 举报类型,用于提交页单选并写入 Mock 举报记录。 +enum WildReportType: String, CaseIterable, Hashable { + case suspectedWild = "疑似打野" + case noWorkBadge = "未戴工牌" + case other = "其他" + + var iconName: String { + switch self { + case .suspectedWild: return "person.crop.circle.badge.exclamationmark" + case .noWorkBadge: return "lanyardcard" + case .other: return "ellipsis.circle" + } + } +} + +/// 举报附件实体,表示一条演示用的上传项。 +struct WildReportAttachment: Hashable { + let id: UUID + let kind: WildReportAttachmentKind + let title: String + + init(id: UUID = UUID(), kind: WildReportAttachmentKind, title: String) { + self.id = id + self.kind = kind + self.title = title + } +} + +/// 举报处理状态。 +enum WildReportStatus: String, Hashable, CaseIterable { + case pending = "待处理" + case processing = "处理中" + case processed = "已处理" + + var displayColor: UIColor { + switch self { + case .pending: return AppColor.warning + case .processing: return AppColor.primary + case .processed: return AppColor.success + } + } + + var backgroundColor: UIColor { + switch self { + case .pending: return AppColor.warningBackground + case .processing: return AppColor.infoBackground + case .processed: return AppColor.successBackground + } + } +} + +/// 我的举报列表筛选条件。 +enum WildReportListFilter: String, CaseIterable, Hashable { + case all = "全部" + case pending = "待处理" + case processing = "处理中" + case processed = "已处理" +} + +/// 补充证据记录实体。 +struct WildReportSupplementaryEvidence: 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: Hashable { + 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: 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): return "未填写" + case (false, false): return "\(wechatID) / \(phoneNumber)" + case (false, true): return wechatID + case (true, false): return phoneNumber + } + } +} + +/// 处理完成后的媒体项。 +struct WildReportCompletionMediaItem: 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: Hashable { + case completed + case current + case pending +} + +/// 举报进度时间轴节点。 +struct WildReportProgressStep: Hashable { + let id: String + let title: String + let timeText: String + let state: WildReportProgressStepState +} + +/// 举报人材料预览项。 +enum WildReportReporterMediaItem: Hashable { + case image(index: Int) + case video(index: Int) + + var id: String { + switch self { + case .image(let index): return "reporter-image-\(index)" + case .video(let index): return "reporter-video-\(index)" + } + } +} + +/// 举报提交结果,用于成功页展示。 +struct WildReportSubmitResult: Hashable { + let record: WildReportRecord + let imageCount: Int + let videoCount: Int +} + +/// 风险地图标记类型。 +enum WildReportMapMarkerKind: Hashable { + case selfLocation + case wildPhotographer + case processingClue + case peerPhotographer + + var color: UIColor { + switch self { + case .selfLocation: return AppColor.primary + case .wildPhotographer: return AppColor.danger + case .processingClue: return AppColor.warning + case .peerPhotographer: return AppColor.success + } + } + + var title: String { + switch self { + case .selfLocation: return "我的位置" + case .wildPhotographer: return "疑似打野" + case .processingClue: return "处理中线索" + case .peerPhotographer: return "内部摄影师" + } + } +} + +/// 雷达地图标记颜色类型。 +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: 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..6be599e --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift @@ -0,0 +1,604 @@ +// +// WildPhotographerReportMockStore.swift +// suixinkan +// + +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 + ) + ] + ] + + 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 + }() + + 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] + + /// 构建已处理举报的处理人信息 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 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)] = [ + redSeed( + id: clueID1, + name: "空中草原观景道", + coordinate: CLLocationCoordinate2D(latitude: 43.4068, longitude: 84.0582), + updatedAt: "5分钟前", + distanceText: "320米", + reporterName: "张三", + reporterPhone: "13333333333", + contents: [ + "多人结伴揽客,疑似无资质摄影团队,携带专业器材在观景道附近活动。", + "补充证据:对方仍在向游客推销拍照套餐,并拒绝出示相关资质。" + ], + imageSeeds: ["nalati-radar-1", "nalati-radar-2", "nalati-radar-3"] + ), + redSeed( + id: clueID2, + name: "塔吾萨尼步道", + coordinate: CLLocationCoordinate2D(latitude: 43.3652, longitude: 84.0385), + updatedAt: "18分钟前", + distanceText: "780米", + reporterName: "李四", + reporterPhone: "18674598210", + contents: [ + "疑似二次转移,仍在塔吾萨尼步道附近揽客拍照。", + "补充:对方穿黑色外套,携带三脚架,在步道入口反复搭讪游客。" + ], + imageSeeds: ["nalati-radar-4", "nalati-radar-5"] + ), + redSeed( + id: clueID3, + name: "河谷草原西南侧", + coordinate: CLLocationCoordinate2D(latitude: 43.3546, longitude: 84.0136), + updatedAt: "昨天17:45", + distanceText: "910米", + reporterName: "王五", + reporterPhone: "15938267120", + contents: ["游客反馈有人在步道旁揽客拍照,行为较为可疑。"], + imageSeeds: ["nalati-radar-8"] + ) + ] + + 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) + ) + ) + } + + markers.append(contentsOf: greenMarkers()) + 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 redSeed( + id: String, + name: String, + coordinate: CLLocationCoordinate2D, + updatedAt: String, + distanceText: String, + reporterName: String, + reporterPhone: String, + contents: [String], + imageSeeds: [String] + ) -> (id: String, name: String, coordinate: CLLocationCoordinate2D, clue: WildReportRadarActiveClue) { + let reportContents = contents.enumerated().map { index, content in + WildReportRadarReportContent(time: index == 0 ? "14:32:18" : "14:45:06", content: content) + } + return ( + id, + name, + coordinate, + WildReportRadarActiveClue( + id: id, + type: .red, + displayTitle: "线索 ID:\(id)", + statusText: "疑似打野", + actionText: "导航到此", + nearestAbnormalText: nil, + distance: nil, + direction: nil, + updatedAt: updatedAt, + name: name, + storeName: nil, + avatar: nil, + summary: nil, + detail: nil, + distanceText: distanceText, + reporterName: reporterName, + reporterPhone: reporterPhone, + maskedReporterPhone: maskPhone(reporterPhone), + reportContents: reportContents, + images: imageSeeds.map { "https://picsum.photos/seed/\($0)/640/480" }, + processingStarted: false, + completed: false, + record: nil, + processingTimeline: [], + completionPhoto: nil + ) + ) + } + + private static func greenMarkers() -> [WildReportMapMarker] { + let seeds: [(id: String, coordinate: CLLocationCoordinate2D, name: String, store: String, detail: String)] = [ + ("PG20240618001", CLLocationCoordinate2D(latitude: 43.4042, longitude: 84.0186), "阿依达娜", "空中草原旅拍店", "当前在空中草原区域服务游客拍摄。"), + ("PG20240618002", CLLocationCoordinate2D(latitude: 43.3985, longitude: 84.0408), "波拉提", "天界台摄影服务点", "当前在天界台附近接待预约游客。"), + ("PG20240618003", CLLocationCoordinate2D(latitude: 43.3828, longitude: 84.0794), "米热丽", "塔吾萨尼影像馆", "当前在塔吾萨尼步道附近提供旅拍服务。") + ] + + return seeds.map { seed in + let clue = WildReportRadarActiveClue( + id: seed.id, + type: .green, + displayTitle: "认证摄影师", + statusText: "认证摄影师", + actionText: "导航到此", + nearestAbnormalText: nil, + distance: "430 米", + direction: "景区内", + updatedAt: "在线", + name: seed.name, + storeName: seed.store, + avatar: "person.crop.circle.fill", + summary: "绿色标记为已认证摄影师,可与红色摄影师风险点位区分。", + detail: seed.detail, + distanceText: nil, + reporterName: nil, + reporterPhone: nil, + maskedReporterPhone: nil, + reportContents: [], + images: [], + processingStarted: false, + completed: false, + record: nil, + processingTimeline: [], + completionPhoto: nil + ) + return WildReportMapMarker( + id: seed.id, + title: seed.name, + coordinate: seed.coordinate, + kind: .peerPhotographer, + detail: .activeClue(clue) + ) + } + } + + 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..f2e130f --- /dev/null +++ b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift @@ -0,0 +1,685 @@ +// +// WildPhotographerReportViewModels.swift +// suixinkan +// + +import Foundation +import MapKit +import UIKit + +/// 举报摄影师首页 ViewModel,持有 Mock 举报数据并供子页面共享。 +final class WildPhotographerReportHomeViewModel { + private(set) var pageState: WildReportPageState = .normal + private(set) var reports: [WildReportRecord] = [] + private(set) var riskPoints: [WildReportRiskPoint] = [] + private(set) var supplementaryEvidenceMap: [String: [WildReportSupplementaryEvidence]] = [:] + + var onStateChange: (() -> Void)? + + private var nextSequence = 0 + private var nextSupplementSequence = 0 + + init() { + resetDemoData(notify: false) + } + + var emptyTitle: String { "暂无举报服务" } + var emptyMessage: String { "当前景区暂未开放举报摄影师演示入口。" } + + /// 恢复演示数据并回到正常状态。 + func restoreDemoData() { + resetDemoData(notify: false) + pageState = .normal + notifyStateChange() + } + + /// 模拟错误后重新加载。 + func retryLoadAfterError() { + pageState = .loading + notifyStateChange() + Task { + try? await Task.sleep(nanoseconds: 450_000_000) + pageState = .normal + notifyStateChange() + } + } + + /// 切换页面演示状态。 + func setPageState(_ state: WildReportPageState) { + pageState = state + notifyStateChange() + } + + /// 提交举报并写入 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) + notifyStateChange() + return record + } + + /// 读取指定举报的补充证据列表。 + func supplementaryEvidences(for reportID: String) -> [WildReportSupplementaryEvidence] { + supplementaryEvidenceMap[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 = supplementaryEvidenceMap[reportID] ?? [] + items.insert(item, at: 0) + supplementaryEvidenceMap[reportID] = items + notifyStateChange() + } + + /// 重置为种子 Mock 数据。 + func resetDemoData(notify: Bool = true) { + reports = WildPhotographerReportMockStore.seedReports + riskPoints = WildPhotographerReportMockStore.seedRiskPoints + supplementaryEvidenceMap = WildPhotographerReportMockStore.seedSupplementaryEvidences + nextSequence = WildPhotographerReportMockStore.maxSequence(from: WildPhotographerReportMockStore.seedReports) + 1 + nextSupplementSequence = 100 + if notify { notifyStateChange() } + } + + 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) + } + + private func notifyStateChange() { + onStateChange?() + } +} + +/// 提交举报页 ViewModel,负责表单校验和前台定位。 +final class WildPhotographerReportSubmitViewModel { + private(set) var pageState: WildReportPageState = .normal + private(set) var images: [WildReportAttachment] + private(set) var videos: [WildReportAttachment] + private(set) var paymentImages: [WildReportAttachment] = [] + private(set) var selectedReportType: WildReportType = .suspectedWild + private(set) var description = "" + private(set) var contact = "" + private(set) var reportLocation = "" + private(set) var isUpdatingLocation = false + private(set) var locationConfirmed = false + private(set) var submitResult: WildReportSubmitResult? + private(set) var validationMessage: String? + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onSubmitSuccess: ((WildReportSubmitResult) -> Void)? + + private let homeViewModel: WildPhotographerReportHomeViewModel + private let locationProvider: any LocationProviding + 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: any LocationProviding = LocationProvider.shared + ) { + self.homeViewModel = homeViewModel + self.locationProvider = locationProvider + self.images = WildPhotographerReportMockStore.initialImages + self.videos = WildPhotographerReportMockStore.initialVideos + } + + /// 首次进入页面时自动请求定位。 + func loadInitialLocationIfNeeded() { + guard !hasRequestedInitialLocation else { return } + hasRequestedInitialLocation = true + refreshCurrentLocation() + } + + /// 选择举报类型。 + func selectReportType(_ type: WildReportType) { + selectedReportType = type + notifyStateChange() + } + + /// 限制举报说明最多 500 字。 + func updateDescription(_ newValue: String) { + description = String(newValue.prefix(500)) + notifyStateChange() + } + + /// 更新联系方式。 + func updateContact(_ value: String) { + contact = String(value.prefix(80)) + notifyStateChange() + } + + /// 添加演示用现场图片。 + func addImage() { + images.append(WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)")) + notifyStateChange() + } + + /// 添加演示用现场视频。 + func addVideo() { + videos.append(WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)")) + notifyStateChange() + } + + /// 添加演示用支付截图。 + func addPaymentImage() { + guard paymentImages.count < 3 else { + onShowMessage?("支付截图最多上传3张") + return + } + paymentImages.append(WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)")) + notifyStateChange() + } + + /// 删除指定附件。 + func removeAttachment(_ attachment: WildReportAttachment) { + images.removeAll { $0.id == attachment.id } + videos.removeAll { $0.id == attachment.id } + paymentImages.removeAll { $0.id == attachment.id } + notifyStateChange() + } + + /// 使用前台定位 Provider 刷新当前位置。 + func refreshCurrentLocation() { + guard !isUpdatingLocation else { return } + isUpdatingLocation = true + locationConfirmed = false + reportLocation = "正在获取当前位置..." + notifyStateChange() + + Task { + do { + let snapshot = try await locationProvider.requestSnapshot() + let address = snapshot.address.isEmpty + ? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude) + : snapshot.address + applyResolvedLocation(address) + } catch { + applyResolvedLocation(WildPhotographerReportMockStore.fallbackLocation) + } + } + } + + /// 清除提交结果,用于导航返回。 + func clearSubmitResult() { + submitResult = nil + notifyStateChange() + } + + /// 校验表单并提交举报。 + func submitReport() { + validationMessage = nil + guard !images.isEmpty || !videos.isEmpty else { + setValidationMessage("请至少上传一项现场证据。") + return + } + guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + setValidationMessage("请填写举报说明。") + return + } + guard !isUpdatingLocation else { + setValidationMessage("正在获取定位,请稍后再提交。") + return + } + guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else { + setValidationMessage("请先更新举报位置后再提交举报。") + return + } + + let record = homeViewModel.submitReport( + reportType: selectedReportType, + description: description, + location: reportLocation, + contact: contact.isEmpty ? nil : contact, + imageCount: images.count, + videoCount: videos.count + ) + let result = WildReportSubmitResult( + record: record, + imageCount: images.count, + videoCount: videos.count + ) + submitResult = result + notifyStateChange() + onSubmitSuccess?(result) + } + + /// 重置表单到初始状态。 + func resetForm() { + images = WildPhotographerReportMockStore.initialImages + videos = WildPhotographerReportMockStore.initialVideos + paymentImages = [] + selectedReportType = .suspectedWild + description = "" + contact = "" + reportLocation = "" + locationConfirmed = false + isUpdatingLocation = false + hasRequestedInitialLocation = false + submitResult = nil + validationMessage = nil + notifyStateChange() + } + + func applyResolvedLocation(_ location: String) { + reportLocation = location + locationConfirmed = true + isUpdatingLocation = false + notifyStateChange() + } + + private func setValidationMessage(_ message: String) { + validationMessage = message + notifyStateChange() + onShowMessage?(message) + } + + private func notifyStateChange() { + onStateChange?() + } +} + +/// 举报成功页 ViewModel。 +final class WildPhotographerReportSuccessViewModel { + 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: " ") + } + + /// 复制举报编号到剪贴板。 + @MainActor + func copyReportID() -> String { + UIPasteboard.general.string = result.record.id + return result.record.id + } +} + +/// 举报详情页 ViewModel。 +final class WildPhotographerReportDetailViewModel { + let record: WildReportRecord + private let homeViewModel: WildPhotographerReportHomeViewModel? + + private(set) var actionMessage: String? + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + 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 descriptionText: String { record.description } + var contactText: String { record.contactDisplayText } + var supplementaryEvidences: [WildReportSupplementaryEvidence] { + homeViewModel?.supplementaryEvidences(for: record.id) ?? [] + } + var reporterMediaSummary: String { + switch (record.imageCount, record.videoCount) { + case (0, 0): return "0项" + case (_, 0): return "\(record.imageCount)张" + case (0, _): return "\(record.videoCount)段" + default: return "\(record.imageCount)张 \(record.videoCount)段" + } + } + var progressSteps: [WildReportProgressStep] { Self.buildProgressSteps(for: record) } + var showHandlerInfo: Bool { record.status == .processed && record.handlerInfo != nil } + var handlerInfo: WildReportHandlerInfo? { record.handlerInfo } + + /// 展示媒体预览提示。 + func showMediaPreview(kind: String) { + showActionMessage("演示环境暂不支持预览\(kind),可在提交页查看上传内容。") + } + + /// 展示位置预览提示。 + func showLocationPreview() { + showActionMessage("举报位置:\(record.location)") + } + + /// 生成分享提示文案。 + func shareReport() { + showActionMessage("已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。") + } + + private func showActionMessage(_ message: String) { + actionMessage = message + onShowMessage?(message) + notifyStateChange() + } + + private static func buildProgressSteps(for record: WildReportRecord) -> [WildReportProgressStep] { + let baseDate = submitDate(from: record.submitTime) ?? Date() + let expectedFeedback = formatDate(baseDate.addingTimeInterval(8 * 3_600)) + + switch record.status { + case .pending, .processing: + return [ + makeStep(id: "1", title: "已提交", date: baseDate, state: .completed), + makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), state: .completed), + makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .current), + makeStep(id: "4", title: "待结果反馈", date: nil, state: .pending, pendingText: "预计 \(expectedFeedback) 前") + ] + case .processed: + return [ + makeStep(id: "1", title: "已提交", date: baseDate, state: .completed), + makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), state: .completed), + makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .completed), + makeStep(id: "4", title: "已反馈核查结果", date: baseDate.addingTimeInterval(3 * 3_600), state: .completed) + ] + } + } + + private static func makeStep( + id: String, + title: String, + date: Date?, + state: WildReportProgressStepState, + pendingText: String? = nil + ) -> WildReportProgressStep { + WildReportProgressStep( + id: id, + title: title, + timeText: pendingText ?? (date.map(formatDate) ?? ""), + state: state + ) + } + + private static func submitDate(from text: String) -> Date? { + WildPhotographerReportMockStore.submitTimeFormatter.date(from: text) + } + + private static func formatDate(_ date: Date) -> String { + WildPhotographerReportMockStore.submitTimeFormatter.string(from: date) + } + + private func notifyStateChange() { + onStateChange?() + } +} + +/// 我的举报列表 ViewModel。 +final class WildPhotographerReportListViewModel { + private(set) var selectedFilter: WildReportListFilter = .all + private(set) var shareMessage: String? + + let homeViewModel: WildPhotographerReportHomeViewModel + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + 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) { + selectedFilter = filter + notifyStateChange() + } + + /// 生成分享提示文案。 + func shareReport(_ record: WildReportRecord) { + shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。" + onShowMessage?(shareMessage ?? "") + notifyStateChange() + } + + private func notifyStateChange() { + onStateChange?() + } +} + +/// 补充证据页 ViewModel。 +final class WildReportSupplementEvidenceViewModel { + private(set) var text = "" + private(set) var images: [WildReportAttachment] = [] + private(set) var videos: [WildReportAttachment] = [] + private(set) var validationMessage: String? + private(set) var didSubmit = false + + let reportID: String + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onSubmitSuccess: (() -> Void)? + + private let homeViewModel: WildPhotographerReportHomeViewModel + + init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) { + self.reportID = reportID + self.homeViewModel = homeViewModel + } + + /// 限制补充说明最多 500 字。 + func updateText(_ newValue: String) { + text = String(newValue.prefix(500)) + notifyStateChange() + } + + /// 添加演示用补充图片。 + func addImage() { + guard images.count < 9 else { + onShowMessage?("补充图片最多上传9张") + return + } + images.append(WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)")) + notifyStateChange() + } + + /// 添加演示用补充视频。 + func addVideo() { + guard videos.count < 3 else { + onShowMessage?("补充视频最多上传3段") + return + } + videos.append(WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)")) + notifyStateChange() + } + + /// 删除指定附件。 + func removeAttachment(_ attachment: WildReportAttachment) { + images.removeAll { $0.id == attachment.id } + videos.removeAll { $0.id == attachment.id } + notifyStateChange() + } + + /// 校验并提交补充证据。 + func submit() { + validationMessage = nil + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedText.isEmpty || !images.isEmpty || !videos.isEmpty else { + validationMessage = "请至少填写补充说明或上传一项证据。" + onShowMessage?(validationMessage ?? "") + notifyStateChange() + return + } + + homeViewModel.appendSupplementaryEvidence( + reportID: reportID, + text: trimmedText, + imageCount: images.count, + videoCount: videos.count + ) + didSubmit = true + notifyStateChange() + onSubmitSuccess?() + } + + private func notifyStateChange() { + onStateChange?() + } +} + +/// 附近风险地图 ViewModel。 +final class WildReportRiskMapViewModel { + private(set) var region: MKCoordinateRegion + private(set) var markers: [WildReportMapMarker] + let scenicAreaName: String + private(set) var selectedMarkerID: String? + private(set) var toastMessage: String? + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + 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 + notifyStateChange() + } + + /// 导航到选中标记或刷新蓝点定位。 + func navigateToSelectedMarker() { + guard let marker = selectedMarker, + case .activeClue(let clue) = marker.detail else { return } + + if clue.type == .blue { + refreshLocation() + return + } + + showToast("已切换到该点位") + region = MKCoordinateRegion( + center: marker.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018) + ) + notifyStateChange() + } + + /// 将地图居中到当前位置标记。 + func refreshLocation() { + guard let blueMarker = markers.first(where: { $0.kind == .selfLocation }) else { return } + selectedMarkerID = blueMarker.id + region = MKCoordinateRegion( + center: blueMarker.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018) + ) + showToast("已定位到当前位置") + notifyStateChange() + } + + /// 生成线索分享提示。 + func shareSelectedClue() { + guard let clue = selectedClue else { return } + let title = clue.type == .red ? "线索 ID:\(clue.id)" : clue.displayTitle + showToast("已生成\(title)的分享信息,可发送给他人查看。") + } + + /// 生成图片预览提示。 + func showImagePreview(_ url: String) { + _ = url + showToast("演示环境暂不支持预览图片") + } + + private func showToast(_ message: String) { + toastMessage = message + onShowMessage?(message) + notifyStateChange() + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift b/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift new file mode 100644 index 0000000..000d8a1 --- /dev/null +++ b/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift @@ -0,0 +1,209 @@ +// +// WildReportCommonViews.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 举报摄影师模块 UI 工具,统一卡片、标签和按钮样式。 +enum WildReportUI { + static func label(_ text: String, font: UIFont, color: UIColor, lines: Int = 1) -> UILabel { + let label = UILabel() + label.text = text + label.font = font + label.textColor = color + label.numberOfLines = lines + return label + } + + static func iconButton(title: String, imageName: String, style: AppButton.Style = .primary) -> UIButton { + let button = UIButton(type: .system) + button.titleLabel?.font = .app(.subtitle) + button.setTitle(title, for: .normal) + button.setImage(UIImage(systemName: imageName), for: .normal) + button.tintColor = style == .primary ? .white : AppColor.primary + button.setTitleColor(style == .primary ? .white : AppColor.primary, for: .normal) + button.backgroundColor = style == .primary ? AppColor.primary : AppColor.primaryLight + button.layer.cornerRadius = AppRadius.md + button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4) + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + return button + } +} + +/// 举报模块白底卡片容器。 +final class WildReportCardView: UIView { + let stack = UIStackView() + + init(spacing: CGFloat = AppSpacing.sm, inset: CGFloat = AppSpacing.md) { + super.init(frame: .zero) + backgroundColor = .white + layer.cornerRadius = AppRadius.lg + layer.borderWidth = 1 + layer.borderColor = AppColor.cardOutline.cgColor + + stack.axis = .vertical + stack.spacing = max(0, spacing - AppSpacing.xxs) + addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(inset) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 举报模块状态标签。 +final class WildReportStatusView: UILabel { + override init(frame: CGRect) { + super.init(frame: frame) + font = .app(.captionMedium) + textAlignment = .center + layer.cornerRadius = 12 + clipsToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(status: WildReportStatus) { + text = status.rawValue + textColor = status.displayColor + backgroundColor = status.backgroundColor + } +} + +/// 举报模块左右信息行。 +final class WildReportInfoRowView: UIView { + private let titleLabel = UILabel() + private let valueLabel = UILabel() + + init(title: String, value: String) { + super.init(frame: .zero) + titleLabel.font = .app(.body) + titleLabel.textColor = AppColor.textSecondary + titleLabel.text = title + valueLabel.font = .app(.bodyMedium) + valueLabel.textColor = AppColor.textPrimary + valueLabel.textAlignment = .right + valueLabel.numberOfLines = 0 + valueLabel.text = value + + addSubview(titleLabel) + addSubview(valueLabel) + titleLabel.snp.makeConstraints { make in + make.leading.top.equalToSuperview() + make.width.greaterThanOrEqualTo(76) + make.bottom.lessThanOrEqualToSuperview() + } + valueLabel.snp.makeConstraints { make in + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(AppSpacing.sm) + make.trailing.top.bottom.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 举报模块附件占位 chip。 +final class WildReportAttachmentChipView: UIView { + private let titleLabel = UILabel() + private let iconView = UIImageView() + var onRemove: (() -> Void)? + + init(attachment: WildReportAttachment, removable: Bool = true) { + super.init(frame: .zero) + backgroundColor = AppColor.pageBackgroundSoft + layer.cornerRadius = AppRadius.md + layer.borderWidth = 1 + layer.borderColor = AppColor.cardOutline.cgColor + + iconView.image = UIImage(systemName: iconName(for: attachment.kind)) + iconView.tintColor = AppColor.primary + titleLabel.text = attachment.title + titleLabel.font = .app(.captionMedium) + titleLabel.textColor = AppColor.textPrimary + + addSubview(iconView) + addSubview(titleLabel) + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(AppSpacing.sm) + make.centerY.equalToSuperview() + make.size.equalTo(18) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.xs) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview().inset(removable ? 34 : AppSpacing.sm) + } + + if removable { + let button = UIButton(type: .system) + button.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + button.tintColor = AppColor.textTertiary + button.addTarget(self, action: #selector(removeTapped), for: .touchUpInside) + addSubview(button) + button.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(AppSpacing.xs) + make.centerY.equalToSuperview() + make.size.equalTo(28) + } + } + + snp.makeConstraints { make in + make.height.equalTo(40) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func iconName(for kind: WildReportAttachmentKind) -> String { + switch kind { + case .image: return "photo.fill" + case .video: return "video.fill" + case .payment: return "creditcard.fill" + } + } + + @objc private func removeTapped() { + onRemove?() + } +} + +/// 举报模块空状态视图。 +final class WildReportEmptyView: UIView { + init(text: String) { + super.init(frame: .zero) + let icon = UIImageView(image: UIImage(systemName: "tray")) + icon.tintColor = AppColor.textTertiary + let label = WildReportUI.label(text, font: .app(.body), color: AppColor.textSecondary, lines: 0) + label.textAlignment = .center + addSubview(icon) + addSubview(label) + icon.snp.makeConstraints { make in + make.top.centerX.equalToSuperview() + make.size.equalTo(42) + } + label.snp.makeConstraints { make in + make.top.equalTo(icon.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.bottom.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift new file mode 100644 index 0000000..30056b1 --- /dev/null +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift @@ -0,0 +1,489 @@ +// +// WildPhotographerReportHomeViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 举报摄影师首页,提供立即举报、我的举报、风险地图和规则入口。 +final class WildPhotographerReportHomeViewController: BaseViewController { + private let viewModel: WildPhotographerReportHomeViewModel + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + + init(viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel()) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "举报摄影师" + navigationItem.largeTitleDisplayMode = .never + let backButton = UIButton(type: .system) + backButton.backgroundColor = .white + backButton.tintColor = AppColor.textPrimary + backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal) + backButton.layer.cornerRadius = 25 + backButton.layer.shadowColor = UIColor.black.cgColor + backButton.layer.shadowOpacity = 0.05 + backButton.layer.shadowOffset = CGSize(width: 0, height: 6) + backButton.layer.shadowRadius = 16 + backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + backButton.snp.makeConstraints { make in + make.size.equalTo(50) + } + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backButton) + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackgroundSoft + scrollView.alwaysBounceVertical = true + scrollView.showsVerticalScrollIndicator = false + scrollView.contentInsetAdjustmentBehavior = .never + contentStack.axis = .vertical + contentStack.spacing = 14 + view.addSubview(scrollView) + scrollView.addSubview(contentStack) + rebuildContent() + } + + override func setupConstraints() { + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalToSuperview() + } + contentStack.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: AppSpacing.md, left: 20, bottom: AppSpacing.md, right: 20)) + make.width.equalTo(scrollView.frameLayoutGuide).offset(-40) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.rebuildContent() } + } + } + + @MainActor + private func rebuildContent() { + contentStack.arrangedSubviews.forEach { view in + contentStack.removeArrangedSubview(view) + view.removeFromSuperview() + } + + switch viewModel.pageState { + case .normal: + contentStack.addArrangedSubview(makeHeroCard()) + contentStack.addArrangedSubview(makeRiskMapEntry()) + contentStack.addArrangedSubview(makeNoticeCard()) + contentStack.addArrangedSubview(makeTrustCard()) + case .loading: + contentStack.addArrangedSubview(makeStateCard(icon: "clock", title: "加载中", message: "正在加载举报服务")) + case .empty: + contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.shield", title: viewModel.emptyTitle, message: viewModel.emptyMessage, buttonTitle: "恢复演示数据") { [weak self] in + self?.viewModel.restoreDemoData() + }) + case .error: + contentStack.addArrangedSubview(makeStateCard(icon: "wifi.exclamationmark", title: "加载失败", message: "举报服务暂时不可用,请稍后重试。", buttonTitle: "重新加载") { [weak self] in + self?.viewModel.retryLoadAfterError() + }) + } + } + + private func makeHeroCard() -> UIView { + let card = WildReportCardView(spacing: 22, inset: 22) + card.backgroundColor = UIColor(hex: 0xFBFDFF) + card.layer.cornerRadius = 26 + card.layer.borderColor = UIColor(hex: 0xBFD7FF).cgColor + + let topRow = UIStackView() + topRow.axis = .horizontal + topRow.alignment = .top + topRow.spacing = AppSpacing.md + + let titleStack = UIStackView() + titleStack.axis = .vertical + titleStack.spacing = 10 + let title = WildReportUI.label("实名举报", font: .systemFont(ofSize: 30, weight: .heavy), color: AppColor.textPrimary) + let subtitle = WildReportUI.label("摄影师", font: .systemFont(ofSize: 30, weight: .heavy), color: AppColor.primary) + titleStack.addArrangedSubview(title) + titleStack.addArrangedSubview(subtitle) + + topRow.addArrangedSubview(titleStack) + topRow.addArrangedSubview(UIView()) + topRow.addArrangedSubview(WildReportHeroIllustrationView()) + + let desc = WildReportUI.label("可上传图片、视频、文字和位置,景区公安将接收并现场处理。", font: .systemFont(ofSize: 17, weight: .regular), color: UIColor(hex: 0x687484), lines: 0) + desc.setContentCompressionResistancePriority(.required, for: .vertical) + + let buttonRow = UIStackView() + buttonRow.axis = .horizontal + buttonRow.spacing = 14 + buttonRow.distribution = .fillEqually + + let submitButton = WildReportUI.iconButton(title: "立即举报", imageName: "checkmark.shield.fill") + submitButton.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold) + submitButton.layer.cornerRadius = 14 + submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) + let listButton = WildReportUI.iconButton(title: "我的举报", imageName: "doc.text.fill", style: .secondary) + listButton.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold) + listButton.layer.cornerRadius = 14 + listButton.backgroundColor = .white + listButton.layer.borderWidth = 1 + listButton.layer.borderColor = UIColor(hex: 0xBFD7FF).cgColor + listButton.addTarget(self, action: #selector(listTapped), for: .touchUpInside) + [submitButton, listButton].forEach { button in + button.snp.makeConstraints { make in make.height.equalTo(58) } + buttonRow.addArrangedSubview(button) + } + + card.stack.addArrangedSubview(topRow) + card.stack.addArrangedSubview(desc) + card.stack.addArrangedSubview(buttonRow) + return card + } + + private func makeRiskMapEntry() -> UIView { + let card = WildReportCardView(spacing: 0, inset: 18) + card.layer.cornerRadius = 20 + let row = makeEntryRow(icon: "map.fill", title: "附近风险地图", subtitle: "查看附近摄影师风险点位") + let button = UIButton(type: .system) + button.addTarget(self, action: #selector(mapTapped), for: .touchUpInside) + card.stack.addArrangedSubview(row) + card.addSubview(button) + button.snp.makeConstraints { make in make.edges.equalToSuperview() } + return card + } + + private func makeNoticeCard() -> UIView { + let card = WildReportCardView(spacing: 18, inset: 18) + card.layer.cornerRadius = 20 + let header = UIStackView() + header.axis = .horizontal + header.alignment = .center + header.spacing = AppSpacing.sm + let headerIcon = UIImageView(image: UIImage(systemName: "list.clipboard.fill")) + headerIcon.tintColor = AppColor.primary + headerIcon.snp.makeConstraints { make in make.size.equalTo(24) } + let title = WildReportUI.label("举报须知", font: .systemFont(ofSize: 18, weight: .bold), color: AppColor.textPrimary) + let ruleButton = UIButton(type: .system) + ruleButton.setTitle("举报规则", for: .normal) + ruleButton.setImage(UIImage(systemName: "shield.lefthalf.filled"), for: .normal) + ruleButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold) + ruleButton.tintColor = AppColor.primary + ruleButton.setTitleColor(AppColor.primary, for: .normal) + ruleButton.backgroundColor = AppColor.primaryLight + ruleButton.layer.cornerRadius = 18 + ruleButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + ruleButton.addTarget(self, action: #selector(ruleTapped), for: .touchUpInside) + ruleButton.snp.makeConstraints { make in + make.height.equalTo(36) + } + header.addArrangedSubview(headerIcon) + header.addArrangedSubview(title) + header.addArrangedSubview(UIView()) + header.addArrangedSubview(ruleButton) + + let items = [ + "需实名登录", + "请尽量上传清晰证据", + "恶意举报将被追责" + ] + card.stack.addArrangedSubview(header) + items.forEach { item in + card.stack.addArrangedSubview(makeBullet(item)) + } + return card + } + + private func makeTrustCard() -> UIView { + let badges = UIStackView() + badges.axis = .horizontal + badges.distribution = .fillEqually + badges.spacing = AppSpacing.sm + [ + ("实名", "身份核验 真实可靠", "person.crop.circle.badge.checkmark", AppColor.primary, AppColor.primaryLight), + ("安全", "数据加密 隐私保护", "checkmark.shield.fill", AppColor.success, AppColor.successBackground), + ("景区公安处理", "专业受理 依法处置", "person.text.rectangle.fill", AppColor.primary, AppColor.primaryLight) + ].forEach { title, subtitle, icon, tint, bg in + badges.addArrangedSubview(WildReportTrustItemView(title: title, subtitle: subtitle, iconName: icon, tintColor: tint, backgroundColor: bg)) + } + return badges + } + + private func makeEntryRow(icon: String, title: String, subtitle: String) -> UIView { + let row = UIStackView() + row.axis = .horizontal + row.alignment = .center + row.spacing = 18 + let iconWrap = UIView() + iconWrap.backgroundColor = AppColor.primaryLight + iconWrap.layer.cornerRadius = 18 + let image = UIImageView(image: UIImage(systemName: icon)) + image.tintColor = AppColor.primary + iconWrap.addSubview(image) + image.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(34) + } + iconWrap.snp.makeConstraints { make in make.size.equalTo(64) } + + let textStack = UIStackView() + textStack.axis = .vertical + textStack.spacing = 6 + textStack.addArrangedSubview(WildReportUI.label(title, font: .systemFont(ofSize: 19, weight: .bold), color: AppColor.textPrimary)) + textStack.addArrangedSubview(WildReportUI.label(subtitle, font: .systemFont(ofSize: 15, weight: .regular), color: AppColor.textSecondary)) + + let chevron = UIImageView(image: UIImage(systemName: "chevron.right")) + chevron.tintColor = AppColor.textTertiary + row.addArrangedSubview(iconWrap) + row.addArrangedSubview(textStack) + row.addArrangedSubview(UIView()) + row.addArrangedSubview(chevron) + return row + } + + private func makeBullet(_ text: String) -> UIView { + let row = UIStackView() + row.axis = .horizontal + row.alignment = .center + row.spacing = 14 + let dot = UIView() + dot.backgroundColor = AppColor.primary + dot.layer.cornerRadius = 4 + dot.snp.makeConstraints { make in + make.size.equalTo(8) + } + let label = WildReportUI.label(text, font: .systemFont(ofSize: 16, weight: .regular), color: UIColor(hex: 0x667085), lines: 0) + row.addArrangedSubview(dot) + row.addArrangedSubview(label) + return row + } + + private func makeStateCard( + icon: String, + title: String, + message: String, + buttonTitle: String? = nil, + action: (() -> Void)? = nil + ) -> UIView { + let card = WildReportCardView(spacing: AppSpacing.md) + card.stack.alignment = .center + let image = UIImageView(image: UIImage(systemName: icon)) + image.tintColor = AppColor.primary + image.snp.makeConstraints { make in make.size.equalTo(48) } + let titleLabel = WildReportUI.label(title, font: .app(.metric), color: AppColor.textPrimary) + let messageLabel = WildReportUI.label(message, font: .app(.body), color: AppColor.textSecondary, lines: 0) + messageLabel.textAlignment = .center + card.stack.addArrangedSubview(image) + card.stack.addArrangedSubview(titleLabel) + card.stack.addArrangedSubview(messageLabel) + if let buttonTitle { + let button = AppButton(title: buttonTitle) + button.addAction(UIAction { _ in action?() }, for: .touchUpInside) + card.stack.addArrangedSubview(button) + button.snp.makeConstraints { make in make.width.equalTo(180) } + } + return card + } + + @objc private func submitTapped() { + navigationController?.pushViewController( + WildPhotographerReportSubmitViewController(homeViewModel: viewModel), + animated: true + ) + } + + @objc private func listTapped() { + navigationController?.pushViewController( + WildPhotographerReportListViewController(homeViewModel: viewModel), + animated: true + ) + } + + @objc private func mapTapped() { + let record = viewModel.reports.first ?? WildPhotographerReportMockStore.seedReports[0] + navigationController?.pushViewController( + WildReportRiskMapViewController(record: record, riskPoints: viewModel.riskPoints), + animated: true + ) + } + + @objc private func ruleTapped() { + navigationController?.pushViewController(WildReportRuleViewController(), animated: true) + } + + @objc private func backTapped() { + navigationController?.popViewController(animated: true) + } +} + +/// 首页 Hero 插画,复刻参考页右上角安全盾牌与相机角标。 +private final class WildReportHeroIllustrationView: UIView { + override init(frame: CGRect) { + super.init(frame: frame) + let ring = UIView() + ring.backgroundColor = UIColor(hex: 0xD9EAFF) + ring.layer.cornerRadius = 58 + + let center = UIView() + center.backgroundColor = .white + center.layer.cornerRadius = 28 + center.layer.shadowColor = UIColor.black.cgColor + center.layer.shadowOpacity = 0.04 + center.layer.shadowRadius = 10 + center.layer.shadowOffset = CGSize(width: 0, height: 4) + + let shield = UIImageView(image: UIImage(systemName: "exclamationmark.shield.fill")) + shield.tintColor = AppColor.primary + shield.contentMode = .scaleAspectFit + + let cameraBadge = UIView() + cameraBadge.backgroundColor = UIColor(hex: 0xE8F3FF) + cameraBadge.layer.cornerRadius = 14 + let camera = UIImageView(image: UIImage(systemName: "camera.fill")) + camera.tintColor = UIColor(hex: 0x195591) + camera.contentMode = .scaleAspectFit + + addSubview(ring) + addSubview(center) + center.addSubview(shield) + addSubview(cameraBadge) + cameraBadge.addSubview(camera) + + ring.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(116) + } + center.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(88) + } + shield.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(52) + } + cameraBadge.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-8) + make.bottom.equalToSuperview().offset(-10) + make.size.equalTo(38) + } + camera.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(22) + } + snp.makeConstraints { make in + make.size.equalTo(116) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 首页底部信任说明小卡。 +private final class WildReportTrustItemView: UIView { + init(title: String, subtitle: String, iconName: String, tintColor: UIColor, backgroundColor: UIColor) { + super.init(frame: .zero) + self.backgroundColor = .white + layer.cornerRadius = 16 + layer.borderWidth = 1 + layer.borderColor = AppColor.cardOutline.cgColor + + let iconWrap = UIView() + iconWrap.backgroundColor = backgroundColor + iconWrap.layer.cornerRadius = 22 + let icon = UIImageView(image: UIImage(systemName: iconName)) + icon.tintColor = tintColor + icon.contentMode = .scaleAspectFit + iconWrap.addSubview(icon) + icon.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(26) + } + + let titleLabel = WildReportUI.label(title, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary) + titleLabel.textAlignment = .center + titleLabel.adjustsFontSizeToFitWidth = true + titleLabel.minimumScaleFactor = 0.78 + titleLabel.lineBreakMode = .byClipping + titleLabel.setContentCompressionResistancePriority(.required, for: .vertical) + + let subtitleLabel = WildReportUI.label(subtitle, font: .systemFont(ofSize: 12, weight: .regular), color: AppColor.textSecondary) + subtitleLabel.textAlignment = .center + subtitleLabel.adjustsFontSizeToFitWidth = true + subtitleLabel.minimumScaleFactor = 0.74 + subtitleLabel.lineBreakMode = .byClipping + subtitleLabel.setContentCompressionResistancePriority(.required, for: .vertical) + + addSubview(iconWrap) + addSubview(titleLabel) + addSubview(subtitleLabel) + iconWrap.snp.makeConstraints { make in + make.top.equalToSuperview().offset(14) + make.centerX.equalToSuperview() + make.size.equalTo(44) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(iconWrap.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview().inset(6) + make.height.equalTo(20) + } + subtitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(4) + make.leading.trailing.equalToSuperview().inset(6) + make.height.equalTo(16) + } + snp.makeConstraints { make in + make.height.equalTo(118) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 举报规则页面。 +final class WildReportRuleViewController: BaseViewController { + private let stack = UIStackView() + + override func setupNavigationBar() { + title = "举报规则" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackgroundSoft + stack.axis = .vertical + stack.spacing = AppSpacing.sm + view.addSubview(stack) + addSection(title: "可举报行为", items: ["疑似打野摄影师主动揽客", "未佩戴工牌开展摄影服务", "引导游客线下付款或私下交易"]) + addSection(title: "材料要求", items: ["尽量提供现场图片、视频、位置和文字说明", "联系方式可选填,用于景区工作人员核实"]) + addSection(title: "处理说明", items: ["举报提交后景区公安会接收线索", "处理进度可在“我的举报”中查看", "请勿提交虚假或恶意举报"]) + } + + override func setupConstraints() { + stack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + } + + private func addSection(title: String, items: [String]) { + let card = WildReportCardView(spacing: AppSpacing.sm) + card.stack.addArrangedSubview(WildReportUI.label(title, font: .app(.title), color: AppColor.textPrimary)) + items.forEach { item in + card.stack.addArrangedSubview(WildReportUI.label("· \(item)", font: .app(.body), color: AppColor.textSecondary, lines: 0)) + } + stack.addArrangedSubview(card) + } +} diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift new file mode 100644 index 0000000..6b04951 --- /dev/null +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift @@ -0,0 +1,356 @@ +// +// WildPhotographerReportListViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 我的举报列表页,支持按状态筛选并进入举报详情。 +final class WildPhotographerReportListViewController: BaseViewController { + private let viewModel: WildPhotographerReportListViewModel + private let segmentedControl = UISegmentedControl(items: WildReportListFilter.allCases.map(\.rawValue)) + private let tableView = UITableView(frame: .zero, style: .plain) + + init(homeViewModel: WildPhotographerReportHomeViewModel) { + self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "我的举报" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackgroundSoft + segmentedControl.selectedSegmentIndex = 0 + segmentedControl.addTarget(self, action: #selector(filterChanged), for: .valueChanged) + + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.delegate = self + tableView.dataSource = self + tableView.register(WildReportListCell.self, forCellReuseIdentifier: WildReportListCell.reuseIdentifier) + tableView.contentInset = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.lg, right: 0) + + view.addSubview(segmentedControl) + view.addSubview(tableView) + } + + override func setupConstraints() { + segmentedControl.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(36) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(segmentedControl.snp.bottom).offset(AppSpacing.xs) + make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.tableView.reloadData() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + } + + @objc private func filterChanged() { + let filter = WildReportListFilter.allCases[segmentedControl.selectedSegmentIndex] + viewModel.selectFilter(filter) + } +} + +extension WildPhotographerReportListViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + let count = viewModel.filteredReports.count + tableView.backgroundView = count == 0 ? WildReportEmptyView(text: "暂无相关举报记录") : nil + return count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: WildReportListCell.reuseIdentifier, for: indexPath) as! WildReportListCell + let record = viewModel.filteredReports[indexPath.row] + cell.apply(record: record) + cell.onShare = { [weak self] in self?.viewModel.shareReport(record) } + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let record = viewModel.filteredReports[indexPath.row] + navigationController?.pushViewController( + WildPhotographerReportDetailViewController(record: record, homeViewModel: viewModel.homeViewModel), + animated: true + ) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + UITableView.automaticDimension + } +} + +/// 我的举报列表卡片 Cell。 +final class WildReportListCell: UITableViewCell { + static let reuseIdentifier = "WildReportListCell" + + private let card = WildReportCardView(spacing: AppSpacing.sm) + private let titleLabel = UILabel() + private let statusView = WildReportStatusView() + private let typeLabel = UILabel() + private let locationLabel = UILabel() + private let timeLabel = UILabel() + private let descLabel = UILabel() + private let materialLabel = UILabel() + var onShare: (() -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + contentView.addSubview(card) + card.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: AppSpacing.xs, left: AppSpacing.md, bottom: AppSpacing.xs, right: AppSpacing.md)) + } + + let header = UIStackView() + header.axis = .horizontal + header.alignment = .center + header.spacing = AppSpacing.sm + titleLabel.font = .app(.title) + titleLabel.textColor = AppColor.textPrimary + header.addArrangedSubview(titleLabel) + header.addArrangedSubview(UIView()) + statusView.snp.makeConstraints { make in + make.width.equalTo(64) + make.height.equalTo(24) + } + header.addArrangedSubview(statusView) + + [typeLabel, locationLabel, timeLabel, descLabel, materialLabel].forEach { label in + label.font = .app(.body) + label.textColor = AppColor.textSecondary + label.numberOfLines = 0 + } + + let shareButton = UIButton(type: .system) + shareButton.setTitle("分享", for: .normal) + shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal) + shareButton.titleLabel?.font = .app(.captionMedium) + shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside) + + card.stack.addArrangedSubview(header) + card.stack.addArrangedSubview(typeLabel) + card.stack.addArrangedSubview(locationLabel) + card.stack.addArrangedSubview(timeLabel) + card.stack.addArrangedSubview(descLabel) + card.stack.addArrangedSubview(materialLabel) + card.stack.addArrangedSubview(shareButton) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(record: WildReportRecord) { + titleLabel.text = record.title + statusView.apply(status: record.status) + typeLabel.text = "举报类型:\(record.reportType.rawValue)" + locationLabel.text = "位置:\(record.location)" + timeLabel.text = "提交时间:\(record.submitTime)" + descLabel.text = record.description + materialLabel.text = "材料:\(record.imageCount)张图片 \(record.videoCount)段视频" + } + + @objc private func shareTapped() { + onShare?() + } +} + +/// 举报详情页。 +final class WildPhotographerReportDetailViewController: BaseViewController { + private let viewModel: WildPhotographerReportDetailViewModel + private let homeViewModel: WildPhotographerReportHomeViewModel + private let scrollView = UIScrollView() + private let stack = UIStackView() + + init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel) { + self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel) + self.homeViewModel = homeViewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "举报详情" + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "square.and.arrow.up"), + style: .plain, + target: self, + action: #selector(shareTapped) + ) + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackgroundSoft + stack.axis = .vertical + stack.spacing = AppSpacing.sm + view.addSubview(scrollView) + scrollView.addSubview(stack) + rebuildContent() + } + + override func setupConstraints() { + scrollView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: AppSpacing.sm, left: AppSpacing.md, bottom: AppSpacing.lg, right: AppSpacing.md)) + make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2) + } + } + + override func bindActions() { + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + homeViewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.rebuildContent() } + } + } + + @MainActor + private func rebuildContent() { + stack.arrangedSubviews.forEach { view in + stack.removeArrangedSubview(view) + view.removeFromSuperview() + } + stack.addArrangedSubview(makeHeaderCard()) + stack.addArrangedSubview(makeProgressCard()) + stack.addArrangedSubview(makeEvidenceCard()) + if viewModel.showHandlerInfo { + stack.addArrangedSubview(makeHandlerCard()) + } + stack.addArrangedSubview(makeSupplementCard()) + stack.addArrangedSubview(makeActionButtons()) + } + + private func makeHeaderCard() -> UIView { + let card = WildReportCardView(spacing: AppSpacing.sm) + let header = UIStackView() + header.axis = .horizontal + header.alignment = .center + header.addArrangedSubview(WildReportUI.label(viewModel.heroTitle, font: .app(.metric), color: AppColor.textPrimary)) + header.addArrangedSubview(UIView()) + let status = WildReportStatusView() + status.apply(status: viewModel.record.status) + status.snp.makeConstraints { make in + make.width.equalTo(64) + make.height.equalTo(24) + } + header.addArrangedSubview(status) + card.stack.addArrangedSubview(header) + card.stack.addArrangedSubview(WildReportInfoRowView(title: "举报编号", value: viewModel.record.id)) + card.stack.addArrangedSubview(WildReportInfoRowView(title: "景区", value: viewModel.scenicAreaName)) + card.stack.addArrangedSubview(WildReportInfoRowView(title: "位置", value: viewModel.detailAddress)) + card.stack.addArrangedSubview(WildReportInfoRowView(title: "联系方式", value: viewModel.contactText)) + return card + } + + private func makeProgressCard() -> UIView { + let card = WildReportCardView(spacing: AppSpacing.sm) + card.stack.addArrangedSubview(WildReportUI.label("处理进度", font: .app(.title), color: AppColor.textPrimary)) + viewModel.progressSteps.forEach { step in + let prefix: String + switch step.state { + case .completed: prefix = "✓" + case .current: prefix = "●" + case .pending: prefix = "○" + } + card.stack.addArrangedSubview(WildReportUI.label("\(prefix) \(step.title) \(step.timeText)", font: .app(.body), color: AppColor.textSecondary, lines: 0)) + } + return card + } + + private func makeEvidenceCard() -> UIView { + let card = WildReportCardView(spacing: AppSpacing.sm) + card.stack.addArrangedSubview(WildReportUI.label("举报证据", font: .app(.title), color: AppColor.textPrimary)) + card.stack.addArrangedSubview(WildReportUI.label(viewModel.descriptionText, font: .app(.body), color: AppColor.textSecondary, lines: 0)) + card.stack.addArrangedSubview(WildReportInfoRowView(title: "举报类型", value: viewModel.record.reportType.rawValue)) + card.stack.addArrangedSubview(WildReportInfoRowView(title: "举报人材料", value: viewModel.reporterMediaSummary)) + let previewButton = WildReportUI.iconButton(title: "预览材料", imageName: "photo.on.rectangle", style: .secondary) + previewButton.addTarget(self, action: #selector(previewTapped), for: .touchUpInside) + card.stack.addArrangedSubview(previewButton) + return card + } + + private func makeHandlerCard() -> UIView { + let card = WildReportCardView(spacing: AppSpacing.sm) + guard let info = viewModel.handlerInfo else { return card } + card.stack.addArrangedSubview(WildReportUI.label("处理信息", font: .app(.title), color: AppColor.textPrimary)) + card.stack.addArrangedSubview(WildReportInfoRowView(title: "处理人", value: info.handlerName)) + card.stack.addArrangedSubview(WildReportInfoRowView(title: "处理电话", value: info.maskedHandlerPhone)) + card.stack.addArrangedSubview(WildReportInfoRowView(title: "处理时间", value: info.processedAt)) + card.stack.addArrangedSubview(WildReportUI.label(info.processOpinion, font: .app(.body), color: AppColor.textSecondary, lines: 0)) + card.stack.addArrangedSubview(WildReportInfoRowView(title: "处理凭证", value: info.completionMediaSummary)) + return card + } + + private func makeSupplementCard() -> UIView { + let card = WildReportCardView(spacing: AppSpacing.sm) + card.stack.addArrangedSubview(WildReportUI.label("补充证据记录", font: .app(.title), color: AppColor.textPrimary)) + let items = viewModel.supplementaryEvidences + if items.isEmpty { + card.stack.addArrangedSubview(WildReportUI.label("暂无补充证据", font: .app(.body), color: AppColor.textSecondary)) + } else { + items.enumerated().forEach { index, item in + card.stack.addArrangedSubview(WildReportUI.label("第\(items.count - index)次补充 · \(item.submitTime)", font: .app(.bodyMedium), color: AppColor.textPrimary, lines: 0)) + card.stack.addArrangedSubview(WildReportUI.label(item.summaryText, font: .app(.body), color: AppColor.textSecondary, lines: 0)) + } + } + return card + } + + private func makeActionButtons() -> UIView { + let row = UIStackView() + row.axis = .vertical + row.spacing = AppSpacing.sm + let supplement = AppButton(title: "补充证据") + supplement.addTarget(self, action: #selector(supplementTapped), for: .touchUpInside) + let map = AppButton(title: "查看附近风险地图", style: .secondary) + map.addTarget(self, action: #selector(mapTapped), for: .touchUpInside) + row.addArrangedSubview(supplement) + row.addArrangedSubview(map) + return row + } + + @objc private func shareTapped() { viewModel.shareReport() } + @objc private func previewTapped() { viewModel.showMediaPreview(kind: "图片/视频") } + + @objc private func supplementTapped() { + navigationController?.pushViewController( + WildReportSupplementEvidenceViewController(reportID: viewModel.record.id, homeViewModel: homeViewModel), + animated: true + ) + } + + @objc private func mapTapped() { + navigationController?.pushViewController( + WildReportRiskMapViewController(record: viewModel.record, riskPoints: homeViewModel.riskPoints), + animated: true + ) + } +} diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift new file mode 100644 index 0000000..6d0088c --- /dev/null +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift @@ -0,0 +1,330 @@ +// +// WildPhotographerReportSubmitViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 提交举报页面,提供类型、证据、说明、定位和联系方式表单。 +final class WildPhotographerReportSubmitViewController: BaseViewController { + private let viewModel: WildPhotographerReportSubmitViewModel + private let homeViewModel: WildPhotographerReportHomeViewModel + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let descriptionView = UITextView() + private let contactField = UITextField() + private let submitButton = AppButton(title: "提交举报") + + init( + homeViewModel: WildPhotographerReportHomeViewModel, + locationProvider: any LocationProviding = LocationProvider.shared + ) { + self.homeViewModel = homeViewModel + self.viewModel = WildPhotographerReportSubmitViewModel( + homeViewModel: homeViewModel, + locationProvider: locationProvider + ) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "提交举报" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackgroundSoft + scrollView.keyboardDismissMode = .interactive + contentStack.axis = .vertical + contentStack.spacing = AppSpacing.sm + + descriptionView.font = .app(.body) + descriptionView.textColor = AppColor.textPrimary + descriptionView.backgroundColor = AppColor.inputBackground + descriptionView.layer.cornerRadius = AppRadius.md + descriptionView.textContainerInset = UIEdgeInsets(top: 12, left: 10, bottom: 12, right: 10) + descriptionView.delegate = self + + contactField.placeholder = "微信号或手机号(选填)" + contactField.font = .app(.body) + contactField.textColor = AppColor.textPrimary + contactField.backgroundColor = AppColor.inputBackground + contactField.layer.cornerRadius = AppRadius.md + contactField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + contactField.leftViewMode = .always + contactField.addTarget(self, action: #selector(contactChanged), for: .editingChanged) + + submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) + + view.addSubview(scrollView) + scrollView.addSubview(contentStack) + view.addSubview(submitButton) + rebuildContent() + } + + override func setupConstraints() { + submitButton.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.sm) + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(submitButton.snp.top).offset(-AppSpacing.sm) + } + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: AppSpacing.sm, left: AppSpacing.md, bottom: AppSpacing.lg, right: AppSpacing.md)) + make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.rebuildContent() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + viewModel.onSubmitSuccess = { [weak self] result in + Task { @MainActor in + guard let self else { return } + self.navigationController?.pushViewController( + WildPhotographerReportSuccessViewController( + result: result, + homeViewModel: self.homeViewModel + ), + animated: true + ) + } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + viewModel.loadInitialLocationIfNeeded() + } + + @MainActor + private func rebuildContent() { + contentStack.arrangedSubviews.forEach { view in + contentStack.removeArrangedSubview(view) + view.removeFromSuperview() + } + contentStack.addArrangedSubview(makeTipsCard()) + contentStack.addArrangedSubview(makeTypeCard()) + contentStack.addArrangedSubview(makeEvidenceCard()) + contentStack.addArrangedSubview(makeDescriptionCard()) + contentStack.addArrangedSubview(makeLocationCard()) + contentStack.addArrangedSubview(makeContactCard()) + contentStack.addArrangedSubview(makePaymentCard()) + } + + private func makeTipsCard() -> UIView { + let card = WildReportCardView(spacing: AppSpacing.xs) + card.stack.addArrangedSubview(WildReportUI.label("实名登录后提交,景区公安将接收并处理", font: .app(.bodyMedium), color: AppColor.textPrimary, lines: 0)) + card.stack.addArrangedSubview(WildReportUI.label("请描述摄影师位置、外貌特征和具体行为。", font: .app(.caption), color: AppColor.textSecondary, lines: 0)) + return card + } + + private func makeTypeCard() -> UIView { + let card = sectionCard(index: 1, title: "选择举报类型") + let row = UIStackView() + row.axis = .vertical + row.spacing = AppSpacing.xs + WildReportType.allCases.forEach { type in + let button = UIButton(type: .system) + button.contentHorizontalAlignment = .left + button.titleLabel?.font = .app(.bodyMedium) + button.setTitle(" \(type.rawValue)", for: .normal) + button.setImage(UIImage(systemName: type == viewModel.selectedReportType ? "largecircle.fill.circle" : "circle"), for: .normal) + button.tintColor = AppColor.primary + button.setTitleColor(AppColor.textPrimary, for: .normal) + button.addAction(UIAction { [weak self] _ in self?.viewModel.selectReportType(type) }, for: .touchUpInside) + button.snp.makeConstraints { make in make.height.equalTo(38) } + row.addArrangedSubview(button) + } + card.stack.addArrangedSubview(row) + return card + } + + private func makeEvidenceCard() -> UIView { + let card = sectionCard(index: 2, title: "上传现场证据") + let buttonRow = UIStackView() + buttonRow.axis = .horizontal + buttonRow.spacing = AppSpacing.sm + buttonRow.distribution = .fillEqually + let imageButton = WildReportUI.iconButton(title: "添加图片", imageName: "photo", style: .secondary) + imageButton.addTarget(self, action: #selector(addImageTapped), for: .touchUpInside) + let videoButton = WildReportUI.iconButton(title: "添加视频", imageName: "video", style: .secondary) + videoButton.addTarget(self, action: #selector(addVideoTapped), for: .touchUpInside) + [imageButton, videoButton].forEach { button in + button.snp.makeConstraints { make in make.height.equalTo(44) } + buttonRow.addArrangedSubview(button) + } + card.stack.addArrangedSubview(buttonRow) + addAttachments(viewModel.images + viewModel.videos, to: card) + return card + } + + private func makeDescriptionCard() -> UIView { + let card = sectionCard(index: 3, title: "举报说明") + descriptionView.text = viewModel.description + card.stack.addArrangedSubview(descriptionView) + descriptionView.snp.makeConstraints { make in + make.height.equalTo(132) + } + let count = WildReportUI.label("\(viewModel.description.count)/500", font: .app(.caption), color: AppColor.textTertiary) + count.textAlignment = .right + card.stack.addArrangedSubview(count) + return card + } + + private func makeLocationCard() -> UIView { + let card = sectionCard(index: 4, title: "举报位置") + let title = WildReportUI.label(viewModel.locationTitleText, font: .app(.bodyMedium), color: AppColor.textPrimary, lines: 0) + let subtitle = WildReportUI.label(viewModel.locationSubtitleText, font: .app(.caption), color: AppColor.textSecondary, lines: 0) + let button = WildReportUI.iconButton(title: viewModel.isUpdatingLocation ? "定位中" : "更新定位", imageName: "location.fill", style: .secondary) + button.isEnabled = !viewModel.isUpdatingLocation + button.addTarget(self, action: #selector(refreshLocationTapped), for: .touchUpInside) + button.snp.makeConstraints { make in make.height.equalTo(42) } + card.stack.addArrangedSubview(title) + card.stack.addArrangedSubview(subtitle) + card.stack.addArrangedSubview(button) + return card + } + + private func makeContactCard() -> UIView { + let card = sectionCard(index: 5, title: "联系方式(选填)") + contactField.text = viewModel.contact + card.stack.addArrangedSubview(contactField) + contactField.snp.makeConstraints { make in make.height.equalTo(46) } + return card + } + + private func makePaymentCard() -> UIView { + let card = sectionCard(index: 6, title: "支付截图(选填)") + card.stack.addArrangedSubview(WildReportUI.label("上传线下微信/支付宝支付截图,最多 3 张。", font: .app(.caption), color: AppColor.textSecondary, lines: 0)) + let button = WildReportUI.iconButton(title: "添加截图", imageName: "creditcard", style: .secondary) + button.addTarget(self, action: #selector(addPaymentTapped), for: .touchUpInside) + button.snp.makeConstraints { make in make.height.equalTo(42) } + card.stack.addArrangedSubview(button) + addAttachments(viewModel.paymentImages, to: card) + return card + } + + private func sectionCard(index: Int, title: String) -> WildReportCardView { + let card = WildReportCardView(spacing: AppSpacing.sm) + card.stack.addArrangedSubview(WildReportUI.label("\(index). \(title)", font: .app(.title), color: AppColor.textPrimary)) + return card + } + + private func addAttachments(_ attachments: [WildReportAttachment], to card: WildReportCardView) { + guard !attachments.isEmpty else { return } + attachments.forEach { attachment in + let chip = WildReportAttachmentChipView(attachment: attachment) + chip.onRemove = { [weak self] in + self?.viewModel.removeAttachment(attachment) + } + card.stack.addArrangedSubview(chip) + } + } + + @objc private func addImageTapped() { viewModel.addImage() } + @objc private func addVideoTapped() { viewModel.addVideo() } + @objc private func addPaymentTapped() { viewModel.addPaymentImage() } + @objc private func refreshLocationTapped() { viewModel.refreshCurrentLocation() } + @objc private func submitTapped() { viewModel.submitReport() } + @objc private func contactChanged() { viewModel.updateContact(contactField.text ?? "") } +} + +extension WildPhotographerReportSubmitViewController: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + viewModel.updateDescription(textView.text) + } +} + +/// 举报成功页面,展示编号、上传摘要和处理进度。 +final class WildPhotographerReportSuccessViewController: BaseViewController { + private let viewModel: WildPhotographerReportSuccessViewModel + private let homeViewModel: WildPhotographerReportHomeViewModel + private let stack = UIStackView() + + init(result: WildReportSubmitResult, homeViewModel: WildPhotographerReportHomeViewModel) { + self.viewModel = WildPhotographerReportSuccessViewModel(result: result) + self.homeViewModel = homeViewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "举报成功" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackgroundSoft + stack.axis = .vertical + stack.spacing = AppSpacing.sm + view.addSubview(stack) + + let hero = WildReportCardView(spacing: AppSpacing.md) + hero.stack.alignment = .center + let icon = UIImageView(image: UIImage(systemName: "checkmark.circle.fill")) + icon.tintColor = AppColor.success + icon.snp.makeConstraints { make in make.size.equalTo(58) } + hero.stack.addArrangedSubview(icon) + hero.stack.addArrangedSubview(WildReportUI.label("举报已提交", font: .app(.metric), color: AppColor.textPrimary)) + let desc = WildReportUI.label("景区公安已收到线索,将尽快前往现场核实处理", font: .app(.body), color: AppColor.textSecondary, lines: 0) + desc.textAlignment = .center + hero.stack.addArrangedSubview(desc) + stack.addArrangedSubview(hero) + + let info = WildReportCardView(spacing: AppSpacing.sm) + info.stack.addArrangedSubview(WildReportInfoRowView(title: "举报编号", value: viewModel.result.record.id)) + info.stack.addArrangedSubview(WildReportInfoRowView(title: "上传材料", value: viewModel.uploadSummary)) + info.stack.addArrangedSubview(WildReportInfoRowView(title: "提交时间", value: viewModel.result.record.submitTime)) + stack.addArrangedSubview(info) + + let progress = WildReportCardView(spacing: AppSpacing.sm) + progress.stack.addArrangedSubview(WildReportUI.label("处理进度", font: .app(.title), color: AppColor.textPrimary)) + viewModel.progressSteps.enumerated().forEach { index, title in + progress.stack.addArrangedSubview(WildReportUI.label("\(index + 1). \(title)", font: .app(.body), color: AppColor.textSecondary)) + } + stack.addArrangedSubview(progress) + + let detailButton = AppButton(title: "查看处理进度") + detailButton.addTarget(self, action: #selector(detailTapped), for: .touchUpInside) + let homeButton = AppButton(title: "返回首页", style: .secondary) + homeButton.addTarget(self, action: #selector(homeTapped), for: .touchUpInside) + stack.addArrangedSubview(detailButton) + stack.addArrangedSubview(homeButton) + } + + override func setupConstraints() { + stack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + } + + @objc private func detailTapped() { + navigationController?.pushViewController( + WildPhotographerReportDetailViewController(record: viewModel.result.record, homeViewModel: homeViewModel), + animated: true + ) + } + + @objc private func homeTapped() { + navigationController?.popToViewController( + navigationController?.viewControllers.first(where: { $0 is WildPhotographerReportHomeViewController }) ?? self, + animated: true + ) + } +} diff --git a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift new file mode 100644 index 0000000..e7f70d5 --- /dev/null +++ b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift @@ -0,0 +1,237 @@ +// +// WildReportRiskMapViewController.swift +// suixinkan +// + +import MapKit +import SnapKit +import UIKit + +/// 附近风险地图页面,展示当前位置、疑似打野、处理中线索和内部摄影师标记。 +final class WildReportRiskMapViewController: BaseViewController { + private let viewModel: WildReportRiskMapViewModel + private let mapView = MKMapView() + private let panel = WildReportCardView(spacing: AppSpacing.sm) + private var annotationMap: [String: WildReportMapMarker] = [:] + + init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) { + self.viewModel = WildReportRiskMapViewModel(record: record, riskPoints: riskPoints) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "附近风险地图" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackgroundSoft + mapView.delegate = self + mapView.showsCompass = false + mapView.showsScale = true + view.addSubview(mapView) + view.addSubview(panel) + addLegend() + applyMap() + rebuildPanel() + } + + override func setupConstraints() { + mapView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.height.equalTo(340) + } + panel.snp.makeConstraints { make in + make.top.equalTo(mapView.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).inset(AppSpacing.sm) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in + self?.mapView.setRegion(self?.viewModel.region ?? MKCoordinateRegion(), animated: true) + self?.rebuildPanel() + } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + } + + private func applyMap() { + mapView.setRegion(viewModel.region, animated: false) + annotationMap.removeAll() + let annotations = viewModel.markers.map { marker in + annotationMap[marker.id] = marker + let annotation = WildReportMapAnnotation(marker: marker) + return annotation + } + mapView.addAnnotations(annotations) + } + + private func addLegend() { + let legend = UIStackView() + legend.axis = .horizontal + legend.distribution = .fillEqually + legend.backgroundColor = UIColor.white.withAlphaComponent(0.94) + legend.layer.cornerRadius = AppRadius.md + legend.clipsToBounds = true + [ + WildReportMapMarkerKind.selfLocation, + .wildPhotographer, + .processingClue, + .peerPhotographer + ].forEach { kind in + let row = UIStackView() + row.axis = .horizontal + row.alignment = .center + row.spacing = 4 + let dot = UIView() + dot.backgroundColor = kind.color + dot.layer.cornerRadius = 4 + dot.snp.makeConstraints { make in make.size.equalTo(8) } + let label = WildReportUI.label(kind.title, font: .app(.caption), color: AppColor.textSecondary) + row.addArrangedSubview(dot) + row.addArrangedSubview(label) + legend.addArrangedSubview(row) + } + view.addSubview(legend) + legend.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm) + make.height.equalTo(36) + } + } + + @MainActor + private func rebuildPanel() { + panel.stack.arrangedSubviews.forEach { view in + panel.stack.removeArrangedSubview(view) + view.removeFromSuperview() + } + guard let marker = viewModel.selectedMarker, + case .activeClue(let clue) = marker.detail else { + panel.stack.addArrangedSubview(WildReportUI.label("点击地图标记查看详情", font: .app(.body), color: AppColor.textSecondary)) + return + } + + let header = UIStackView() + header.axis = .horizontal + header.alignment = .center + let title = WildReportUI.label(clue.displayTitle, font: .app(.title), color: AppColor.textPrimary, lines: 0) + let badge = UILabel() + badge.text = clue.statusText + badge.font = .app(.captionMedium) + badge.textColor = marker.kind.color + badge.textAlignment = .center + badge.backgroundColor = marker.kind.color.withAlphaComponent(0.12) + badge.layer.cornerRadius = 12 + badge.clipsToBounds = true + badge.snp.makeConstraints { make in + make.width.greaterThanOrEqualTo(74) + make.height.equalTo(24) + } + header.addArrangedSubview(title) + header.addArrangedSubview(UIView()) + header.addArrangedSubview(badge) + panel.stack.addArrangedSubview(header) + + if let nearest = clue.nearestAbnormalText { + panel.stack.addArrangedSubview(WildReportUI.label(nearest, font: .app(.bodyMedium), color: AppColor.danger, lines: 0)) + } + if let name = clue.name { + panel.stack.addArrangedSubview(WildReportInfoRowView(title: "点位", value: name)) + } + if let store = clue.storeName { + panel.stack.addArrangedSubview(WildReportInfoRowView(title: "门店", value: store)) + } + if let distance = clue.distanceText ?? clue.distance { + panel.stack.addArrangedSubview(WildReportInfoRowView(title: "距离", value: distance)) + } + if let updatedAt = clue.updatedAt { + panel.stack.addArrangedSubview(WildReportInfoRowView(title: "时间", value: updatedAt)) + } + if let summary = clue.summary ?? clue.detail { + panel.stack.addArrangedSubview(WildReportUI.label(summary, font: .app(.body), color: AppColor.textSecondary, lines: 0)) + } + clue.reportContents.prefix(2).forEach { content in + panel.stack.addArrangedSubview(WildReportUI.label("\(content.time) \(content.content)", font: .app(.caption), color: AppColor.textSecondary, lines: 0)) + } + if let record = clue.record { + panel.stack.addArrangedSubview(WildReportInfoRowView(title: "处理人", value: record.handlerName)) + panel.stack.addArrangedSubview(WildReportUI.label(record.processResult, font: .app(.body), color: AppColor.textSecondary, lines: 0)) + } + + let buttonRow = UIStackView() + buttonRow.axis = .horizontal + buttonRow.spacing = AppSpacing.sm + buttonRow.distribution = .fillEqually + let navButton = WildReportUI.iconButton(title: clue.actionText, imageName: "location.fill", style: .secondary) + navButton.addTarget(self, action: #selector(navigateTapped), for: .touchUpInside) + let shareButton = WildReportUI.iconButton(title: "分享", imageName: "square.and.arrow.up", style: .secondary) + shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside) + [navButton, shareButton].forEach { button in + button.snp.makeConstraints { make in make.height.equalTo(42) } + buttonRow.addArrangedSubview(button) + } + panel.stack.addArrangedSubview(buttonRow) + } + + @objc private func navigateTapped() { + viewModel.navigateToSelectedMarker() + } + + @objc private func shareTapped() { + viewModel.shareSelectedClue() + } +} + +extension WildReportRiskMapViewController: MKMapViewDelegate { + func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { + guard let annotation = annotation as? WildReportMapAnnotation else { return nil } + let identifier = "WildReportMapAnnotationView" + let view = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) + ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier) + view.annotation = annotation + if let markerView = view as? MKMarkerAnnotationView { + markerView.markerTintColor = annotation.marker.kind.color + markerView.glyphImage = UIImage(systemName: glyphName(for: annotation.marker.kind)) + markerView.glyphTintColor = .white + markerView.canShowCallout = false + markerView.animatesWhenAdded = true + } + return view + } + + func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) { + guard let annotation = annotation as? WildReportMapAnnotation else { return } + viewModel.selectMarker(annotation.marker) + } + + private func glyphName(for kind: WildReportMapMarkerKind) -> String { + switch kind { + case .selfLocation: return "location.fill" + case .wildPhotographer: return "exclamationmark" + case .processingClue: return "clock.fill" + case .peerPhotographer: return "camera.fill" + } + } +} + +/// 风险地图 MKAnnotation 包装。 +final class WildReportMapAnnotation: NSObject, MKAnnotation { + let marker: WildReportMapMarker + var coordinate: CLLocationCoordinate2D { marker.coordinate } + var title: String? { marker.title } + + init(marker: WildReportMapMarker) { + self.marker = marker + super.init() + } +} diff --git a/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift new file mode 100644 index 0000000..5d6ed80 --- /dev/null +++ b/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift @@ -0,0 +1,128 @@ +// +// WildReportSupplementEvidenceViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 补充证据页面,允许为已有举报追加说明和 Mock 附件。 +final class WildReportSupplementEvidenceViewController: BaseViewController { + private let viewModel: WildReportSupplementEvidenceViewModel + private let scrollView = UIScrollView() + private let stack = UIStackView() + private let textView = UITextView() + private let submitButton = AppButton(title: "提交补充证据") + + init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) { + self.viewModel = WildReportSupplementEvidenceViewModel(reportID: reportID, homeViewModel: homeViewModel) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "补充证据" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackgroundSoft + scrollView.keyboardDismissMode = .interactive + stack.axis = .vertical + stack.spacing = AppSpacing.sm + textView.font = .app(.body) + textView.textColor = AppColor.textPrimary + textView.backgroundColor = AppColor.inputBackground + textView.layer.cornerRadius = AppRadius.md + textView.textContainerInset = UIEdgeInsets(top: 12, left: 10, bottom: 12, right: 10) + textView.delegate = self + submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) + + view.addSubview(scrollView) + scrollView.addSubview(stack) + view.addSubview(submitButton) + rebuildContent() + } + + override func setupConstraints() { + submitButton.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.sm) + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(submitButton.snp.top).offset(-AppSpacing.sm) + } + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: AppSpacing.sm, left: AppSpacing.md, bottom: AppSpacing.lg, right: AppSpacing.md)) + make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.rebuildContent() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + viewModel.onSubmitSuccess = { [weak self] in + Task { @MainActor in + self?.showToast("补充证据已提交") + self?.navigationController?.popViewController(animated: true) + } + } + } + + @MainActor + private func rebuildContent() { + stack.arrangedSubviews.forEach { view in + stack.removeArrangedSubview(view) + view.removeFromSuperview() + } + let textCard = WildReportCardView(spacing: AppSpacing.sm) + textCard.stack.addArrangedSubview(WildReportUI.label("补充说明", font: .app(.title), color: AppColor.textPrimary)) + textView.text = viewModel.text + textCard.stack.addArrangedSubview(textView) + textView.snp.makeConstraints { make in make.height.equalTo(150) } + let count = WildReportUI.label("\(viewModel.text.count)/500", font: .app(.caption), color: AppColor.textTertiary) + count.textAlignment = .right + textCard.stack.addArrangedSubview(count) + stack.addArrangedSubview(textCard) + + let attachmentCard = WildReportCardView(spacing: AppSpacing.sm) + attachmentCard.stack.addArrangedSubview(WildReportUI.label("补充材料", font: .app(.title), color: AppColor.textPrimary)) + let row = UIStackView() + row.axis = .horizontal + row.spacing = AppSpacing.sm + row.distribution = .fillEqually + let imageButton = WildReportUI.iconButton(title: "添加图片", imageName: "photo", style: .secondary) + imageButton.addTarget(self, action: #selector(addImageTapped), for: .touchUpInside) + let videoButton = WildReportUI.iconButton(title: "添加视频", imageName: "video", style: .secondary) + videoButton.addTarget(self, action: #selector(addVideoTapped), for: .touchUpInside) + [imageButton, videoButton].forEach { button in + button.snp.makeConstraints { make in make.height.equalTo(44) } + row.addArrangedSubview(button) + } + attachmentCard.stack.addArrangedSubview(row) + (viewModel.images + viewModel.videos).forEach { attachment in + let chip = WildReportAttachmentChipView(attachment: attachment) + chip.onRemove = { [weak self] in self?.viewModel.removeAttachment(attachment) } + attachmentCard.stack.addArrangedSubview(chip) + } + stack.addArrangedSubview(attachmentCard) + } + + @objc private func addImageTapped() { viewModel.addImage() } + @objc private func addVideoTapped() { viewModel.addVideo() } + @objc private func submitTapped() { viewModel.submit() } +} + +extension WildReportSupplementEvidenceViewController: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + viewModel.updateText(textView.text) + } +} diff --git a/suixinkanTests/AllFunctionsViewModelTests.swift b/suixinkanTests/AllFunctionsViewModelTests.swift index 4630020..4eadbd9 100644 --- a/suixinkanTests/AllFunctionsViewModelTests.swift +++ b/suixinkanTests/AllFunctionsViewModelTests.swift @@ -120,4 +120,27 @@ final class AllFunctionsViewModelTests: XCTestCase { XCTAssertEqual(viewModel.moreMenus.map(\.uri), ["wallet"]) XCTAssertTrue(viewModel.didCustomize) } + + func testPhotographerReportMenuVisibleAndCanBeAddedToCommon() { + appStore.savePermissionItems([ + HomePermissionItem(id: "1", name: "位置上报", uri: "location_report"), + HomePermissionItem(id: "2", name: "举报摄影师", uri: "report_photographer"), + ]) + menuStore.saveCommonURIs( + ["location_report"], + accountScope: appStore.accountCachePrefix, + roleCode: appStore.roleCode + ) + let viewModel = AllFunctionsViewModel(appStore: appStore, commonMenuStore: menuStore) + + viewModel.loadFunctions() + + XCTAssertEqual(HomeMenuCatalog.item(for: "report_photographer")?.title, "举报摄影师") + XCTAssertEqual(viewModel.moreMenus.map(\.uri), ["report_photographer"]) + + let menu = viewModel.moreMenus[0] + viewModel.addToCommon(menu) + + XCTAssertEqual(viewModel.commonMenus.map(\.uri), ["location_report", "report_photographer"]) + } } diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift index b086431..5e844f5 100644 --- a/suixinkanTests/HomeCommonMenuStoreTests.swift +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -58,4 +58,11 @@ final class HomeCommonMenuStoreTests: XCTestCase { XCTAssertEqual(split.common.map(\.uri), ["wallet", "task_management"]) XCTAssertEqual(split.more.map(\.uri), ["cloud_management"]) } + + func testPhotographerReportCatalogItemExists() { + let item = HomeMenuCatalog.item(for: "report_photographer") + + XCTAssertEqual(item?.title, "举报摄影师") + XCTAssertEqual(item?.iconName, "exclamationmark.shield") + } } diff --git a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift new file mode 100644 index 0000000..68bd47c --- /dev/null +++ b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift @@ -0,0 +1,122 @@ +// +// WildPhotographerReportTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 举报摄影师模块测试,覆盖 Mock 数据、提交校验、列表筛选和补充证据。 +final class WildPhotographerReportTests: XCTestCase { + func testSeedDataLoadsReportsAndRiskPoints() { + let viewModel = WildPhotographerReportHomeViewModel() + + XCTAssertFalse(viewModel.reports.isEmpty) + XCTAssertFalse(viewModel.riskPoints.isEmpty) + XCTAssertEqual(viewModel.reports.count, WildPhotographerReportMockStore.seedReports.count) + } + + func testSubmitReportGeneratesDynamicIDAndInsertsFirst() { + 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.phoneNumber, "13800138000") + } + + func testSubmitFailsWithoutEvidence() { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) + viewModel.removeAttachment(viewModel.images[0]) + viewModel.removeAttachment(viewModel.videos[0]) + viewModel.updateDescription("有说明") + viewModel.applyResolvedLocation("测试位置") + + viewModel.submitReport() + + XCTAssertEqual(viewModel.validationMessage, "请至少上传一项现场证据。") + XCTAssertNil(viewModel.submitResult) + } + + func testSubmitFailsWithoutDescription() { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) + viewModel.updateDescription(" ") + viewModel.applyResolvedLocation("测试位置") + + viewModel.submitReport() + + XCTAssertEqual(viewModel.validationMessage, "请填写举报说明。") + XCTAssertNil(viewModel.submitResult) + } + + func testSubmitFailsWithoutConfirmedLocation() { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) + viewModel.updateDescription("测试说明") + + viewModel.submitReport() + + XCTAssertEqual(viewModel.validationMessage, "请先更新举报位置后再提交举报。") + XCTAssertNil(viewModel.submitResult) + } + + func testSubmitSuccessCreatesRecord() { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) + viewModel.updateDescription("测试说明") + viewModel.applyResolvedLocation("测试景区 · 测试点") + + viewModel.submitReport() + + XCTAssertNil(viewModel.validationMessage) + XCTAssertNotNil(viewModel.submitResult) + XCTAssertEqual(homeViewModel.reports.first?.id, viewModel.submitResult?.record.id) + } + + func testListFilterReturnsMatchingStatus() { + 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) + } + + func testSupplementEvidenceValidationAndSuccess() { + let homeViewModel = WildPhotographerReportHomeViewModel() + let reportID = homeViewModel.reports[0].id + let viewModel = WildReportSupplementEvidenceViewModel(reportID: reportID, homeViewModel: homeViewModel) + + viewModel.submit() + XCTAssertEqual(viewModel.validationMessage, "请至少填写补充说明或上传一项证据。") + + viewModel.updateText("补充说明") + viewModel.addImage() + viewModel.submit() + + XCTAssertTrue(viewModel.didSubmit) + XCTAssertEqual(homeViewModel.supplementaryEvidences(for: reportID).first?.text, "补充说明") + XCTAssertEqual(homeViewModel.supplementaryEvidences(for: reportID).first?.imageCount, 1) + } +}