feat: 更新素材管理和举报风险地图
This commit is contained in:
@ -22,126 +22,83 @@ protocol WildReportAttachmentUploading {
|
||||
|
||||
extension OSSUploadService: WildReportAttachmentUploading {}
|
||||
|
||||
/// 举报摄影师首页 ViewModel,持有 Mock 举报数据并供子页面共享。
|
||||
/// 举报摄影师模块通用日期格式化工具。
|
||||
enum WildReportDateFormatters {
|
||||
static let displayDateTime: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.timeZone = .current
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
/// 举报摄影师模块通用联系方式解析工具。
|
||||
enum WildReportContactParser {
|
||||
/// 将用户输入的联系方式拆分为手机号和微信号。
|
||||
static func parse(_ contact: String?) -> (phone: String?, wechat: String?) {
|
||||
let trimmed = contact?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else { return (nil, nil) }
|
||||
let digits = trimmed.filter(\.isNumber)
|
||||
if digits.count >= 7 {
|
||||
return (trimmed, nil)
|
||||
}
|
||||
return (nil, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报摄影师首页 ViewModel,维护首页远端文案和跨页面共享状态。
|
||||
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]] = [:]
|
||||
private(set) var noticeItems: [String] = []
|
||||
private(set) var ruleGroups: [WildReportRuleGroup] = []
|
||||
private(set) var isLoadingCopy = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
private var nextSequence = 0
|
||||
private var nextSupplementSequence = 0
|
||||
|
||||
init() {
|
||||
resetDemoData(notify: false)
|
||||
}
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
var emptyTitle: String { "暂无举报服务" }
|
||||
var emptyMessage: String { "当前景区暂未开放举报摄影师演示入口。" }
|
||||
var emptyMessage: String { "当前景区暂未开放举报摄影师入口。" }
|
||||
var shouldShowNoticeModule: Bool { !noticeItems.isEmpty || !ruleGroups.isEmpty }
|
||||
var shouldShowRuleButton: Bool { !ruleGroups.isEmpty }
|
||||
|
||||
/// 恢复演示数据并回到正常状态。
|
||||
func restoreDemoData() {
|
||||
resetDemoData(notify: false)
|
||||
pageState = .normal
|
||||
/// 加载首页举报须知与规则文案。
|
||||
func loadReportCopy(api: any WildPhotographerReportServing) async {
|
||||
guard !isLoadingCopy else { return }
|
||||
isLoadingCopy = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 模拟错误后重新加载。
|
||||
func retryLoadAfterError() {
|
||||
pageState = .loading
|
||||
notifyStateChange()
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 450_000_000)
|
||||
pageState = .normal
|
||||
defer {
|
||||
isLoadingCopy = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
let response = try await api.reportCopy()
|
||||
noticeItems = response.notice
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
ruleGroups = response.ruleGroups.compactMap { group in
|
||||
let title = group.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let items = group.items
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard !title.isEmpty || !items.isEmpty else { return nil }
|
||||
return WildReportRuleGroup(title: title, items: items)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
noticeItems = []
|
||||
ruleGroups = []
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换页面演示状态。
|
||||
/// 切换页面状态。
|
||||
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?()
|
||||
}
|
||||
@ -153,8 +110,8 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
private(set) var images: [WildReportAttachment]
|
||||
private(set) var videos: [WildReportAttachment]
|
||||
private(set) var paymentImages: [WildReportAttachment] = []
|
||||
private(set) var reportTypes: [WildReportType] = WildReportType.fallbackOptions
|
||||
private(set) var selectedReportType: WildReportType = .defaultSelected
|
||||
private(set) var reportTypes: [WildReportType] = []
|
||||
private(set) var selectedReportType: WildReportType?
|
||||
private(set) var description = ""
|
||||
private(set) var contact = ""
|
||||
private(set) var reportLocation = ""
|
||||
@ -209,7 +166,7 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
refreshCurrentLocation()
|
||||
}
|
||||
|
||||
/// 从服务端加载举报类型,失败时回退到本地兜底选项。
|
||||
/// 从服务端加载举报类型。
|
||||
func loadReportTypes(api: any WildPhotographerReportServing) async {
|
||||
guard !isLoadingReportTypes else { return }
|
||||
isLoadingReportTypes = true
|
||||
@ -222,22 +179,19 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
do {
|
||||
let response = try await api.reportTypes()
|
||||
let loadedTypes = uniqueReportTypes(response.list)
|
||||
reportTypes = loadedTypes.isEmpty ? WildReportType.fallbackOptions : loadedTypes
|
||||
if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) {
|
||||
reportTypes = loadedTypes
|
||||
if let currentSelectedType = selectedReportType,
|
||||
let updatedType = reportTypes.first(where: { $0.value == currentSelectedType.value }) {
|
||||
selectedReportType = updatedType
|
||||
} else {
|
||||
selectedReportType = reportTypes.first ?? .defaultSelected
|
||||
selectedReportType = reportTypes.first
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
reportTypes = WildReportType.fallbackOptions
|
||||
if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) {
|
||||
selectedReportType = updatedType
|
||||
} else {
|
||||
selectedReportType = .defaultSelected
|
||||
}
|
||||
onShowMessage?("举报类型获取失败,已使用默认类型")
|
||||
reportTypes = []
|
||||
selectedReportType = nil
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@ -270,11 +224,6 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用现场图片。
|
||||
func addImage() {
|
||||
addImages([WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)")])
|
||||
}
|
||||
|
||||
/// 添加本地现场视频。
|
||||
func addVideos(_ attachments: [WildReportAttachment]) {
|
||||
let videoRemaining = max(0, 3 - videos.count)
|
||||
@ -288,11 +237,6 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用现场视频。
|
||||
func addVideo() {
|
||||
addVideos([WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)")])
|
||||
}
|
||||
|
||||
/// 添加本地支付截图。
|
||||
func addPaymentImages(_ attachments: [WildReportAttachment]) {
|
||||
guard paymentImages.count < 3 else {
|
||||
@ -303,11 +247,6 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用支付截图。
|
||||
func addPaymentImage() {
|
||||
addPaymentImages([WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)")])
|
||||
}
|
||||
|
||||
/// 删除指定附件。
|
||||
func removeAttachment(_ attachment: WildReportAttachment) {
|
||||
images.removeAll { $0.id == attachment.id }
|
||||
@ -329,11 +268,7 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
let snapshot = try await locationProvider.requestSnapshot()
|
||||
applyResolvedLocation(snapshot)
|
||||
} catch {
|
||||
applyResolvedLocation(HomeLocationSnapshot(
|
||||
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
|
||||
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
|
||||
address: WildPhotographerReportMockStore.fallbackLocation
|
||||
))
|
||||
applyLocationFailure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -389,14 +324,7 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
paymentScreenshots: paymentRequests
|
||||
))
|
||||
|
||||
let record = homeViewModel.submitReport(
|
||||
reportType: selectedReportType,
|
||||
description: description,
|
||||
location: reportLocation,
|
||||
contact: contact.isEmpty ? nil : contact,
|
||||
imageCount: images.count,
|
||||
videoCount: videos.count
|
||||
)
|
||||
let record = buildSubmittedRecord()
|
||||
let result = WildReportSubmitResult(
|
||||
record: record,
|
||||
imageCount: images.count,
|
||||
@ -412,36 +340,13 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验表单并写入本地 Mock 记录。
|
||||
func submitReport() {
|
||||
validationMessage = nil
|
||||
guard validateBeforeSubmit(scenicId: 1) else { 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 = []
|
||||
videos = []
|
||||
paymentImages = []
|
||||
reportTypes = WildReportType.fallbackOptions
|
||||
selectedReportType = .defaultSelected
|
||||
reportTypes = []
|
||||
selectedReportType = nil
|
||||
description = ""
|
||||
contact = ""
|
||||
reportLocation = ""
|
||||
@ -456,14 +361,6 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func applyResolvedLocation(_ location: String) {
|
||||
applyResolvedLocation(HomeLocationSnapshot(
|
||||
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
|
||||
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
|
||||
address: location
|
||||
))
|
||||
}
|
||||
|
||||
func applyResolvedLocation(_ snapshot: HomeLocationSnapshot) {
|
||||
let address = snapshot.address.isEmpty
|
||||
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
|
||||
@ -479,6 +376,15 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func applyLocationFailure(_ message: String) {
|
||||
reportLocation = ""
|
||||
locationSnapshot = nil
|
||||
locationConfirmed = false
|
||||
isUpdatingLocation = false
|
||||
notifyStateChange()
|
||||
onShowMessage?(message)
|
||||
}
|
||||
|
||||
private func setValidationMessage(_ message: String) {
|
||||
validationMessage = message
|
||||
notifyStateChange()
|
||||
@ -503,6 +409,10 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
setValidationMessage("请先选择景区后再提交举报。")
|
||||
return false
|
||||
}
|
||||
guard selectedReportType != nil else {
|
||||
setValidationMessage("请先选择举报类型。")
|
||||
return false
|
||||
}
|
||||
guard !images.isEmpty || !videos.isEmpty else {
|
||||
setValidationMessage("请至少上传一项现场证据。")
|
||||
return false
|
||||
@ -527,6 +437,10 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
setValidationMessage("请先更新举报位置后再提交举报。")
|
||||
return false
|
||||
}
|
||||
guard locationSnapshot != nil else {
|
||||
setValidationMessage("缺少定位坐标,请重新更新举报位置。")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@ -572,15 +486,11 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
let locationParts = WildReportLocationParser.split(reportLocation)
|
||||
let trimmedScenicName = scenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let locationName = !trimmedScenicName.isEmpty ? trimmedScenicName : locationParts.scenic
|
||||
let snapshot = locationSnapshot ?? HomeLocationSnapshot(
|
||||
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
|
||||
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
|
||||
address: reportLocation
|
||||
)
|
||||
let snapshot = locationSnapshot ?? HomeLocationSnapshot(latitude: 0, longitude: 0, address: reportLocation)
|
||||
let trimmedContact = contact.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return WildReportSubmitRequest(
|
||||
scenicId: scenicId,
|
||||
reportType: selectedReportType.value,
|
||||
reportType: selectedReportType?.value ?? 0,
|
||||
desc: description.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
storeUserId: nil,
|
||||
contactPhone: trimmedContact.isEmpty ? nil : trimmedContact,
|
||||
@ -592,6 +502,25 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
paymentScreenshots: paymentScreenshots
|
||||
)
|
||||
}
|
||||
|
||||
private func buildSubmittedRecord() -> WildReportRecord {
|
||||
let parsedContact = WildReportContactParser.parse(contact)
|
||||
return WildReportRecord(
|
||||
serverID: nil,
|
||||
id: "",
|
||||
title: "摄影师举报",
|
||||
reportType: selectedReportType ?? WildReportType(value: 0, label: ""),
|
||||
status: .pending,
|
||||
location: reportLocation,
|
||||
submitTime: WildReportDateFormatters.displayDateTime.string(from: Date()),
|
||||
description: description.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
wechatID: parsedContact.wechat ?? "",
|
||||
phoneNumber: parsedContact.phone ?? "",
|
||||
imageCount: images.count,
|
||||
videoCount: videos.count,
|
||||
handlerInfo: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报成功页 ViewModel。
|
||||
@ -642,6 +571,17 @@ final class WildPhotographerReportDetailViewModel {
|
||||
let detail = WildReportLocationParser.split(record.location).detail
|
||||
return detail.contains("附近") ? detail : "\(detail)附近"
|
||||
}
|
||||
var mapCoordinate: CLLocationCoordinate2D? {
|
||||
guard let latitudeText = detail?.latitude,
|
||||
let longitudeText = detail?.longitude,
|
||||
let latitude = Double(latitudeText),
|
||||
let longitude = Double(longitudeText) else {
|
||||
return nil
|
||||
}
|
||||
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||
guard CLLocationCoordinate2DIsValid(coordinate), latitude != 0 || longitude != 0 else { return nil }
|
||||
return coordinate
|
||||
}
|
||||
|
||||
var displayStatusText: String { detail?.displayStatusText ?? record.status.rawValue }
|
||||
var descriptionText: String { record.description }
|
||||
@ -651,7 +591,7 @@ final class WildPhotographerReportDetailViewModel {
|
||||
if let detail {
|
||||
return detail.supplementRecords
|
||||
}
|
||||
return homeViewModel?.supplementaryEvidences(for: record.id) ?? []
|
||||
return []
|
||||
}
|
||||
var reporterMediaItems: [WildReportReporterMediaItem] {
|
||||
if let detail {
|
||||
@ -718,7 +658,7 @@ final class WildPhotographerReportDetailViewModel {
|
||||
|
||||
/// 展示媒体预览提示。
|
||||
func showMediaPreview(kind: String) {
|
||||
showActionMessage("演示环境暂不支持预览\(kind),可在提交页查看上传内容。")
|
||||
showActionMessage("暂不支持预览\(kind)")
|
||||
}
|
||||
|
||||
/// 展示位置预览提示。
|
||||
@ -738,30 +678,14 @@ final class WildPhotographerReportDetailViewModel {
|
||||
}
|
||||
|
||||
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, .rejected:
|
||||
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: record.status == .rejected ? "已驳回举报" : "已反馈核查结果",
|
||||
date: baseDate.addingTimeInterval(3 * 3_600),
|
||||
state: .completed
|
||||
)
|
||||
]
|
||||
}
|
||||
[
|
||||
WildReportProgressStep(
|
||||
id: "current-status",
|
||||
title: record.status.rawValue,
|
||||
timeText: record.submitTime,
|
||||
state: record.status == .pending || record.status == .processing ? .current : .completed
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private static func buildProgressSteps(
|
||||
@ -780,29 +704,6 @@ final class WildPhotographerReportDetailViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
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?()
|
||||
}
|
||||
@ -855,13 +756,6 @@ final class WildPhotographerReportListViewModel {
|
||||
await reload(api: api, scenicId: scenicId)
|
||||
}
|
||||
|
||||
/// 保留本地筛选切换能力,供旧单元测试和 Mock 场景使用。
|
||||
func selectFilter(_ filter: WildReportListFilter) {
|
||||
selectedFilter = filter
|
||||
reports = filterLocalReports(homeViewModel.reports)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 滚动到底部时加载下一页。
|
||||
func loadMoreIfNeeded(api: any WildPhotographerReportServing, scenicId: Int?) async {
|
||||
guard hasMore, !isLoading, !isLoadingMore else { return }
|
||||
@ -935,15 +829,6 @@ final class WildPhotographerReportListViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
private func filterLocalReports(_ localReports: [WildReportRecord]) -> [WildReportRecord] {
|
||||
switch selectedFilter {
|
||||
case .all: return localReports
|
||||
case .pending: return localReports.filter { $0.status == .pending }
|
||||
case .processing: return localReports.filter { $0.status == .processing }
|
||||
case .processed: return localReports.filter { $0.status == .processed }
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
@ -990,11 +875,6 @@ final class WildReportSupplementEvidenceViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用补充图片。
|
||||
func addImage() {
|
||||
addImages([WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)")])
|
||||
}
|
||||
|
||||
/// 添加本地补充视频。
|
||||
func addVideos(_ attachments: [WildReportAttachment]) {
|
||||
let videoRemaining = max(0, 3 - videos.count)
|
||||
@ -1008,11 +888,6 @@ final class WildReportSupplementEvidenceViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用补充视频。
|
||||
func addVideo() {
|
||||
addVideos([WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)")])
|
||||
}
|
||||
|
||||
/// 删除指定附件。
|
||||
func removeAttachment(_ attachment: WildReportAttachment) {
|
||||
images.removeAll { $0.id == attachment.id }
|
||||
@ -1055,12 +930,6 @@ final class WildReportSupplementEvidenceViewModel {
|
||||
evidences: evidenceRequests.isEmpty ? nil : evidenceRequests
|
||||
))
|
||||
|
||||
homeViewModel.appendSupplementaryEvidence(
|
||||
reportID: reportID,
|
||||
text: trimmedText,
|
||||
imageCount: images.count,
|
||||
videoCount: videos.count
|
||||
)
|
||||
didSubmit = true
|
||||
notifyStateChange()
|
||||
onSubmitSuccess?()
|
||||
@ -1141,7 +1010,8 @@ final class WildReportSupplementEvidenceViewModel {
|
||||
|
||||
/// 附近风险地图 ViewModel。
|
||||
final class WildReportRiskMapViewModel {
|
||||
private let fallbackMarkers: [WildReportMapMarker]
|
||||
private static let riskMapLocationAccuracy = kCLLocationAccuracyHundredMeters
|
||||
|
||||
private var loadedDetailMarkerIDs: Set<String> = []
|
||||
|
||||
private(set) var region: MKCoordinateRegion
|
||||
@ -1159,14 +1029,12 @@ final class WildReportRiskMapViewModel {
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) {
|
||||
let markers = WildPhotographerReportMockStore.buildMarkers(record: record, riskPoints: riskPoints)
|
||||
self.fallbackMarkers = markers
|
||||
self.markers = markers
|
||||
self.region = WildPhotographerReportMockStore.region(for: markers)
|
||||
init() {
|
||||
self.markers = []
|
||||
self.region = Self.region(for: [])
|
||||
let scenicName = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.scenicAreaName = scenicName.isEmpty ? "那拉提景区" : scenicName
|
||||
self.selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id
|
||||
self.scenicAreaName = scenicName.isEmpty ? "当前景区" : scenicName
|
||||
self.selectedMarkerID = nil
|
||||
}
|
||||
|
||||
var selectedMarker: WildReportMapMarker? {
|
||||
@ -1272,7 +1140,7 @@ final class WildReportRiskMapViewModel {
|
||||
/// 生成图片预览提示。
|
||||
func showImagePreview(_ url: String) {
|
||||
_ = url
|
||||
showToast("演示环境暂不支持预览图片")
|
||||
showToast("暂不支持预览图片")
|
||||
}
|
||||
|
||||
private func showToast(_ message: String) {
|
||||
@ -1295,16 +1163,37 @@ final class WildReportRiskMapViewModel {
|
||||
return
|
||||
}
|
||||
|
||||
let coordinate = await requestRiskMapCoordinate(locationProvider: locationProvider)
|
||||
currentCoordinate = coordinate
|
||||
await requestRiskMap(
|
||||
api: api,
|
||||
scenicId: resolvedScenicId,
|
||||
scenicName: scenicName,
|
||||
coordinate: coordinate,
|
||||
selectCurrentLocation: selectCurrentLocation,
|
||||
showsLocationRefreshToast: coordinate != nil
|
||||
)
|
||||
}
|
||||
|
||||
private func requestRiskMapCoordinate(locationProvider: any LocationProviding) async -> CLLocationCoordinate2D? {
|
||||
try? await locationProvider.requestCoordinate(desiredAccuracy: Self.riskMapLocationAccuracy)
|
||||
}
|
||||
|
||||
private func requestRiskMap(
|
||||
api: any WildPhotographerReportServing,
|
||||
scenicId: Int,
|
||||
scenicName: String?,
|
||||
coordinate: CLLocationCoordinate2D?,
|
||||
selectCurrentLocation: Bool,
|
||||
showsLocationRefreshToast: Bool
|
||||
) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
notifyStateChange()
|
||||
|
||||
let coordinate = try? await locationProvider.requestCoordinate()
|
||||
currentCoordinate = coordinate
|
||||
|
||||
do {
|
||||
let response = try await api.riskMap(WildReportRiskMapRequest(
|
||||
scenicId: resolvedScenicId,
|
||||
scenicId: scenicId,
|
||||
latitude: coordinate?.latitude,
|
||||
longitude: coordinate?.longitude
|
||||
))
|
||||
@ -1315,25 +1204,17 @@ final class WildReportRiskMapViewModel {
|
||||
greenMarkerTip = response.greenMarkerTip
|
||||
legendItems = response.legend
|
||||
markers = buildMarkers(response: response, currentCoordinate: coordinate)
|
||||
if markers.isEmpty {
|
||||
markers = fallbackMarkers
|
||||
}
|
||||
region = WildPhotographerReportMockStore.region(for: markers)
|
||||
region = Self.region(for: markers)
|
||||
if selectCurrentLocation, let current = markers.first(where: { $0.kind == .selfLocation }) {
|
||||
selectedMarkerID = current.id
|
||||
} else if selectedMarker == nil {
|
||||
selectedMarkerID = markers.first?.id
|
||||
}
|
||||
if coordinate != nil {
|
||||
if showsLocationRefreshToast {
|
||||
showToast("已更新附近风险点位")
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
if markers.isEmpty {
|
||||
markers = fallbackMarkers
|
||||
region = WildPhotographerReportMockStore.region(for: markers)
|
||||
selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id ?? markers.first?.id
|
||||
}
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
|
||||
@ -1538,7 +1419,7 @@ final class WildReportRiskMapViewModel {
|
||||
case .yellow:
|
||||
return "处理中线索"
|
||||
case .red, .unknown:
|
||||
return dto.reportTypeText.nonEmpty ?? "疑似打野"
|
||||
return dto.reportTypeText.nonEmpty ?? "异常线索"
|
||||
case .blue:
|
||||
return "当前位置"
|
||||
}
|
||||
@ -1563,6 +1444,32 @@ final class WildReportRiskMapViewModel {
|
||||
return String(format: "%.1f公里", meters / 1000)
|
||||
}
|
||||
|
||||
private static func region(for markers: [WildReportMapMarker]) -> MKCoordinateRegion {
|
||||
guard !markers.isEmpty else {
|
||||
return MKCoordinateRegion(
|
||||
center: CLLocationCoordinate2D(latitude: 0, longitude: 0),
|
||||
span: MKCoordinateSpan(latitudeDelta: 0.08, longitudeDelta: 0.08)
|
||||
)
|
||||
}
|
||||
let latitudes = markers.map(\.coordinate.latitude)
|
||||
let longitudes = markers.map(\.coordinate.longitude)
|
||||
let minLat = latitudes.min() ?? markers[0].coordinate.latitude
|
||||
let maxLat = latitudes.max() ?? markers[0].coordinate.latitude
|
||||
let minLon = longitudes.min() ?? markers[0].coordinate.longitude
|
||||
let maxLon = longitudes.max() ?? markers[0].coordinate.longitude
|
||||
let center = CLLocationCoordinate2D(
|
||||
latitude: (minLat + maxLat) / 2,
|
||||
longitude: (minLon + maxLon) / 2
|
||||
)
|
||||
return MKCoordinateRegion(
|
||||
center: center,
|
||||
span: MKCoordinateSpan(
|
||||
latitudeDelta: max(0.018, (maxLat - minLat) * 1.6),
|
||||
longitudeDelta: max(0.018, (maxLon - minLon) * 1.6)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func maskPhone(_ phone: String) -> String {
|
||||
guard phone.count == 11, phone.allSatisfy(\.isNumber) else { return phone }
|
||||
return "\(phone.prefix(3))****\(phone.suffix(4))"
|
||||
|
||||
Reference in New Issue
Block a user