新增景区权限模块,集成 AMap CocoaPods 并支持模拟器
将景区选择与权限申请流程接入首页路由,集成高德 SDK 用于真机构建,并通过 Podfile post_install 钩子保证 arm64 模拟器可编译。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,212 @@
|
||||
//
|
||||
// PermissionApplyViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 权限申请角色选项实体,表示可申请的一个业务角色。
|
||||
struct PermissionRoleOption: Equatable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let notes: String
|
||||
}
|
||||
|
||||
/// 权限申请景区选项实体,表示可申请或已拥有权限的一个景区。
|
||||
struct PermissionScenicOption: Equatable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
var selected: Bool
|
||||
var disabled: Bool
|
||||
}
|
||||
|
||||
/// 已有角色权限展示实体,表示一个角色及其已拥有景区。
|
||||
struct ExistingRolePermissionInfo: Equatable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let scenics: [ScenicInfo]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 权限申请 ViewModel,负责角色/景区选择和提交审核。
|
||||
final class PermissionApplyViewModel {
|
||||
var roleOptions: [PermissionRoleOption] = []
|
||||
var selectedRoleId: Int?
|
||||
var scenicOptions: [PermissionScenicOption] = []
|
||||
var loadingScenics = false
|
||||
var scenicLoadFailed = false
|
||||
var scenicLoadFailureReason: String?
|
||||
var submitting = false
|
||||
var message: String?
|
||||
|
||||
private let initialPending: RoleApplyPendingResponse?
|
||||
private var rolePermissions: [RolePermissionResponse] = []
|
||||
private var selectedIds: Set<Int> = []
|
||||
|
||||
/// 初始化权限申请 ViewModel,可传入驳回申请用于编辑回填。
|
||||
init(initialPending: RoleApplyPendingResponse? = nil) {
|
||||
self.initialPending = initialPending
|
||||
}
|
||||
|
||||
/// 返回是否允许提交审核。
|
||||
var canSubmit: Bool {
|
||||
!submitting && selectedRoleId != nil && !selectedIds.isEmpty
|
||||
}
|
||||
|
||||
/// 返回已选择景区数量。
|
||||
var selectedCount: Int {
|
||||
selectedIds.count
|
||||
}
|
||||
|
||||
/// 返回已选择景区 ID 集合,供测试和提交校验使用。
|
||||
var selectedScenicIds: Set<Int> {
|
||||
selectedIds
|
||||
}
|
||||
|
||||
/// 返回景区选择摘要文案。
|
||||
var selectedScenicSummary: String {
|
||||
let names = scenicOptions
|
||||
.filter { !$0.disabled && selectedIds.contains($0.id) }
|
||||
.map(\.name)
|
||||
if names.isEmpty { return "请选择景区" }
|
||||
if names.count == 1 { return names[0] }
|
||||
return "已选择 \(names.count) 个景区"
|
||||
}
|
||||
|
||||
/// 返回当前选择角色的说明。
|
||||
var selectedRoleNotes: String? {
|
||||
guard let selectedRoleId else { return nil }
|
||||
return roleOptions.first(where: { $0.id == selectedRoleId })?.notes
|
||||
}
|
||||
|
||||
/// 返回当前选择角色名称。
|
||||
var selectedRoleName: String? {
|
||||
guard let selectedRoleId else { return nil }
|
||||
return roleOptions.first(where: { $0.id == selectedRoleId })?.name
|
||||
}
|
||||
|
||||
/// 返回已有角色权限展示列表。
|
||||
var existingRoleInfos: [ExistingRolePermissionInfo] {
|
||||
rolePermissions.map {
|
||||
ExistingRolePermissionInfo(id: $0.role.id, name: $0.role.name, scenics: $0.scenic)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从权限上下文启动表单数据。
|
||||
func bootstrap(rolePermissions: [RolePermissionResponse]) {
|
||||
self.rolePermissions = rolePermissions
|
||||
roleOptions = uniqueRoles(from: rolePermissions)
|
||||
if let pending = initialPending {
|
||||
selectedRoleId = pending.roleId
|
||||
selectedIds = Set(pending.scenicList.map(\.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择角色,并清空当前景区选择等待重新加载。
|
||||
func selectRole(id: Int) {
|
||||
selectedRoleId = id
|
||||
selectedIds.removeAll()
|
||||
scenicOptions = []
|
||||
scenicLoadFailed = false
|
||||
scenicLoadFailureReason = nil
|
||||
}
|
||||
|
||||
/// 按当前角色加载可申请景区列表。
|
||||
func loadScenicListIfNeeded(api: any ScenicPermissionServing, force: Bool = false) async {
|
||||
guard selectedRoleId != nil else { return }
|
||||
if !force && !scenicOptions.isEmpty { return }
|
||||
loadingScenics = true
|
||||
scenicLoadFailed = false
|
||||
scenicLoadFailureReason = nil
|
||||
defer { loadingScenics = false }
|
||||
do {
|
||||
let list = try await api.scenicListAll().list
|
||||
let disabledIds = Set(currentRolePermission()?.scenic.map(\.id) ?? [])
|
||||
scenicOptions = list.map { scenic in
|
||||
let selected = disabledIds.contains(scenic.id) || selectedIds.contains(scenic.id)
|
||||
return PermissionScenicOption(id: scenic.id, name: scenic.name, selected: selected, disabled: disabledIds.contains(scenic.id))
|
||||
}
|
||||
selectedIds = Set(scenicOptions.filter { $0.selected && !$0.disabled }.map(\.id))
|
||||
} catch {
|
||||
scenicOptions = []
|
||||
selectedIds.removeAll()
|
||||
scenicLoadFailed = true
|
||||
scenicLoadFailureReason = error.localizedDescription
|
||||
message = "景区列表加载失败,请重试"
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换一个景区的选择状态,已有权限的景区不可切换。
|
||||
func toggleScenic(id: Int) {
|
||||
guard let index = scenicOptions.firstIndex(where: { $0.id == id }), !scenicOptions[index].disabled else { return }
|
||||
scenicOptions[index].selected.toggle()
|
||||
if scenicOptions[index].selected {
|
||||
selectedIds.insert(id)
|
||||
} else {
|
||||
selectedIds.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交角色权限申请。
|
||||
func submit(api: any ScenicPermissionServing) async {
|
||||
guard let roleId = selectedRoleId, !selectedIds.isEmpty, !submitting else { return }
|
||||
submitting = true
|
||||
defer { submitting = false }
|
||||
do {
|
||||
try await api.roleApplySubmit(roleId: roleId, scenicIds: Array(selectedIds).sorted())
|
||||
message = "提交成功,等待审核"
|
||||
} catch {
|
||||
message = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成去重后的角色选项。
|
||||
private func uniqueRoles(from list: [RolePermissionResponse]) -> [PermissionRoleOption] {
|
||||
var seen = Set<Int>()
|
||||
return list.compactMap { item in
|
||||
guard seen.insert(item.role.id).inserted else { return nil }
|
||||
return PermissionRoleOption(id: item.role.id, name: item.role.name, notes: item.role.notes ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回当前选择角色对应的权限实体。
|
||||
private func currentRolePermission() -> RolePermissionResponse? {
|
||||
guard let selectedRoleId else { return nil }
|
||||
return rolePermissions.first { $0.role.id == selectedRoleId }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 权限申请状态 ViewModel,负责读取审核中的或指定编号的权限申请。
|
||||
final class PermissionApplyStatusViewModel {
|
||||
var loading = false
|
||||
var pending: RoleApplyPendingResponse?
|
||||
var loadFailed = false
|
||||
var loadFailureReason: String?
|
||||
|
||||
/// 加载权限申请状态;有申请编号时优先匹配编号,否则展示审核中或驳回记录。
|
||||
func load(api: any ScenicPermissionServing, applyCode: String?) async {
|
||||
loading = true
|
||||
loadFailed = false
|
||||
loadFailureReason = nil
|
||||
defer { loading = false }
|
||||
do {
|
||||
let all = try await api.roleApplyAll()
|
||||
let normalizedCode = applyCode?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !normalizedCode.isEmpty {
|
||||
pending = all.first { $0.code == normalizedCode }
|
||||
} else {
|
||||
pending = all.first { $0.status == 1 || $0.status == 3 }
|
||||
}
|
||||
} catch {
|
||||
pending = nil
|
||||
loadFailed = true
|
||||
loadFailureReason = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,317 @@
|
||||
//
|
||||
// ScenicApplicationViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 景区申请本地图片草稿实体,表示尚未或正在上传的图片。
|
||||
struct ScenicApplicationImageDraft: Equatable, Identifiable {
|
||||
let id: UUID
|
||||
let data: Data
|
||||
let fileName: String
|
||||
var uploadedURL: String?
|
||||
var uploading: Bool
|
||||
var progress: Int
|
||||
|
||||
/// 创建景区申请图片草稿。
|
||||
init(id: UUID = UUID(), data: Data, fileName: String, uploadedURL: String? = nil, uploading: Bool = false, progress: Int = 0) {
|
||||
self.id = id
|
||||
self.data = data
|
||||
self.fileName = fileName
|
||||
self.uploadedURL = uploadedURL
|
||||
self.uploading = uploading
|
||||
self.progress = progress
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景区申请 ViewModel,负责新增景区申请表单、图片上传和待审核记录回填。
|
||||
final class ScenicApplicationViewModel {
|
||||
var scenicName = ""
|
||||
var imageURLs = ""
|
||||
var localImages: [ScenicApplicationImageDraft] = []
|
||||
var selectedProvince = ""
|
||||
var selectedCity = ""
|
||||
var coopType = 1
|
||||
var remark = ""
|
||||
var agreed = false
|
||||
var submitting = false
|
||||
var message: String?
|
||||
var provinces: [ScenicAreaNode] = []
|
||||
var cities: [ScenicAreaNode] = []
|
||||
var pending: ScenicApplicationPendingResponse?
|
||||
var loading = false
|
||||
var loadFailed = false
|
||||
var loadFailureReason: String?
|
||||
var pendingLoadFailed = false
|
||||
var pendingLoadFailureReason: String?
|
||||
|
||||
/// 返回当前申请是否只读;审核中的申请不可再次编辑提交。
|
||||
var isReadOnly: Bool {
|
||||
pending?.status == 1
|
||||
}
|
||||
|
||||
/// 返回当前表单是否允许提交。
|
||||
var canSubmit: Bool {
|
||||
!isReadOnly && !submitting && validationMessage == nil
|
||||
}
|
||||
|
||||
/// 返回提交按钮是否需要锁定。
|
||||
var isSubmitLocked: Bool {
|
||||
isReadOnly || submitting
|
||||
}
|
||||
|
||||
/// 返回远程图片和本地图片总数。
|
||||
var uploadPlaceholderCount: Int {
|
||||
remoteImageURLs.count + localImages.count
|
||||
}
|
||||
|
||||
/// 返回按行或逗号分割后的远程图片 URL。
|
||||
var remoteImageURLs: [String] {
|
||||
imageURLs
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.flatMap { $0.split(separator: ",") }
|
||||
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
/// 加载地区树和待审核景区申请。
|
||||
func loadInitial(api: any ScenicPermissionServing) async {
|
||||
loading = true
|
||||
loadFailed = false
|
||||
loadFailureReason = nil
|
||||
defer { loading = false }
|
||||
do {
|
||||
provinces = try await api.areas()
|
||||
} catch {
|
||||
let reason = "地区数据加载失败:\(error.localizedDescription)"
|
||||
message = reason
|
||||
loadFailed = true
|
||||
loadFailureReason = reason
|
||||
provinces = []
|
||||
cities = []
|
||||
clearPendingApplication()
|
||||
return
|
||||
}
|
||||
await reloadPending(api: api)
|
||||
}
|
||||
|
||||
/// 重新加载待审核、审核中或驳回的景区申请。
|
||||
func reloadPending(api: any ScenicPermissionServing) async {
|
||||
pendingLoadFailed = false
|
||||
pendingLoadFailureReason = nil
|
||||
do {
|
||||
let pendingList = try await api.scenicApplicationPendingAll().items
|
||||
if let first = pendingList.first(where: { $0.status == 1 || $0.status == 3 || $0.status == 9 }) {
|
||||
applyPending(first)
|
||||
} else {
|
||||
clearPendingApplication()
|
||||
}
|
||||
} catch {
|
||||
clearPendingApplication()
|
||||
pendingLoadFailed = true
|
||||
pendingLoadFailureReason = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 省份变更后刷新城市列表,并在旧城市不匹配时清空城市。
|
||||
func onProvinceChange() {
|
||||
let oldCity = selectedCity
|
||||
cities = provinces.first(where: { $0.name == selectedProvince })?.children ?? []
|
||||
if !cities.contains(where: { $0.name == oldCity }) {
|
||||
selectedCity = ""
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加一张本地景区图片。
|
||||
func addLocalImage(data: Data, fileName: String) {
|
||||
guard uploadPlaceholderCount < 20 else {
|
||||
message = "最多上传20张景区图片"
|
||||
return
|
||||
}
|
||||
localImages.append(ScenicApplicationImageDraft(data: data, fileName: fileName))
|
||||
}
|
||||
|
||||
/// 删除一张远程图片。
|
||||
func removeRemoteImage(_ url: String) {
|
||||
imageURLs = remoteImageURLs.filter { $0 != url }.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// 删除一张本地图片。
|
||||
func removeLocalImage(id: UUID) {
|
||||
localImages.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 提交景区申请;本地图片会先上传 OSS,全部成功后再提交业务表单。
|
||||
func submit(api: any ScenicPermissionServing, uploader: any OSSUploadServing) async {
|
||||
if let validationMessage {
|
||||
message = validationMessage
|
||||
return
|
||||
}
|
||||
guard !isSubmitLocked else { return }
|
||||
submitting = true
|
||||
defer { submitting = false }
|
||||
do {
|
||||
let uploaded = try await uploadLocalImagesIfNeeded(uploader: uploader)
|
||||
let urls = remoteImageURLs + uploaded
|
||||
if !urls.isEmpty {
|
||||
let remotePlaceholders = remoteImageURLs.map { makeUploadPlaceholder(from: $0, fileSize: 0) }
|
||||
let localPlaceholders = localImages.compactMap { item -> ScenicApplicationUploadPlaceholder? in
|
||||
guard let uploadedURL = item.uploadedURL else { return nil }
|
||||
return makeUploadPlaceholder(from: uploadedURL, fileName: item.fileName, fileSize: Int64(item.data.count))
|
||||
}
|
||||
try await api.scenicApplicationUploadPlaceholder(remotePlaceholders + localPlaceholders)
|
||||
}
|
||||
try await api.scenicSubmit(
|
||||
ScenicApplicationSubmitRequest(
|
||||
scenicName: trimmedNilIfEmpty(scenicName),
|
||||
scenicImages: urls.isEmpty ? nil : urls,
|
||||
scenicProvince: selectedProvince,
|
||||
scenicCity: selectedCity,
|
||||
coopType: coopType,
|
||||
remark: remark,
|
||||
scenicId: pending?.scenicId ?? 0
|
||||
)
|
||||
)
|
||||
await reloadPending(api: api)
|
||||
message = "提交成功"
|
||||
} catch {
|
||||
message = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回当前表单校验错误文案。
|
||||
var validationMessage: String? {
|
||||
if scenicName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return "请输入景区名称"
|
||||
}
|
||||
if uploadPlaceholderCount == 0 {
|
||||
return "请至少上传一张景区图片"
|
||||
}
|
||||
if selectedProvince.isEmpty {
|
||||
return "请选择省份"
|
||||
}
|
||||
if selectedCity.isEmpty {
|
||||
return "请选择城市"
|
||||
}
|
||||
if !agreed {
|
||||
return "请先阅读并同意用户须知与隐私政策"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 上传尚未上传的本地图片,并返回最终 URL 列表。
|
||||
private func uploadLocalImagesIfNeeded(uploader: any OSSUploadServing) async throws -> [String] {
|
||||
var urls: [String] = []
|
||||
for index in localImages.indices {
|
||||
if let uploadedURL = localImages[index].uploadedURL {
|
||||
urls.append(uploadedURL)
|
||||
continue
|
||||
}
|
||||
localImages[index].uploading = true
|
||||
localImages[index].progress = max(localImages[index].progress, 1)
|
||||
do {
|
||||
let uploadedURL = try await uploader.uploadScenicApplicationImage(
|
||||
data: localImages[index].data,
|
||||
fileName: localImages[index].fileName,
|
||||
scenicId: pending?.scenicId ?? 0
|
||||
) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
guard let self, self.localImages.indices.contains(index) else { return }
|
||||
self.localImages[index].progress = progress
|
||||
}
|
||||
}
|
||||
localImages[index].uploadedURL = uploadedURL
|
||||
localImages[index].uploading = false
|
||||
localImages[index].progress = 100
|
||||
urls.append(uploadedURL)
|
||||
} catch {
|
||||
localImages[index].uploading = false
|
||||
localImages[index].progress = 0
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
/// 使用待审核记录回填表单。
|
||||
private func applyPending(_ item: ScenicApplicationPendingResponse) {
|
||||
pending = item
|
||||
scenicName = item.scenicName
|
||||
imageURLs = item.scenicImages
|
||||
.split(separator: ",")
|
||||
.map(String.init)
|
||||
.joined(separator: "\n")
|
||||
selectedProvince = item.scenicProvince
|
||||
selectedCity = item.scenicCity
|
||||
coopType = item.coopType
|
||||
remark = item.remark ?? ""
|
||||
agreed = true
|
||||
localImages = []
|
||||
onProvinceChange()
|
||||
}
|
||||
|
||||
/// 清空待审核记录和表单内容。
|
||||
private func clearPendingApplication() {
|
||||
pending = nil
|
||||
scenicName = ""
|
||||
imageURLs = ""
|
||||
localImages = []
|
||||
selectedProvince = ""
|
||||
selectedCity = ""
|
||||
cities = []
|
||||
coopType = 1
|
||||
remark = ""
|
||||
agreed = false
|
||||
}
|
||||
|
||||
/// 创建上传占位请求实体。
|
||||
private func makeUploadPlaceholder(from url: String, fileName: String? = nil, fileSize: Int64) -> ScenicApplicationUploadPlaceholder {
|
||||
ScenicApplicationUploadPlaceholder(
|
||||
fileName: fileName ?? ScenicUploadPlaceholderPolicy.fileName(from: url, fallbackPrefix: "scenic_upload"),
|
||||
fileType: ScenicUploadPlaceholderPolicy.fileType(from: url),
|
||||
fileSize: fileSize
|
||||
)
|
||||
}
|
||||
|
||||
/// 返回去空白后的非空字符串,空值返回 nil。
|
||||
private func trimmedNilIfEmpty(_ value: String) -> String? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区申请上传占位策略,负责从 URL 或文件名提取占位元信息。
|
||||
enum ScenicUploadPlaceholderPolicy {
|
||||
/// 从原始 URL 或路径中提取文件名,缺失时使用稳定前缀兜底。
|
||||
static func fileName(from rawValue: String, fallbackPrefix: String, uuid: UUID = UUID()) -> String {
|
||||
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stripped = trimmed
|
||||
.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first
|
||||
.map(String.init)?
|
||||
.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first
|
||||
.map(String.init) ?? trimmed
|
||||
let urlPathName = URL(string: trimmed)?.lastPathComponent
|
||||
let pathName = (stripped as NSString).lastPathComponent
|
||||
let decodedName = (urlPathName?.isEmpty == false ? urlPathName : pathName)?.removingPercentEncoding
|
||||
let fileName = decodedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !fileName.isEmpty, fileName != "/" else {
|
||||
return "\(fallbackPrefix)_\(uuid.uuidString.prefix(8))"
|
||||
}
|
||||
return fileName
|
||||
}
|
||||
|
||||
/// 根据文件扩展名推断上传占位文件类型。
|
||||
static func fileType(from rawValue: String) -> String {
|
||||
let fileName = fileName(from: rawValue, fallbackPrefix: "upload")
|
||||
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
|
||||
if ["mp4", "mov", "mkv", "avi"].contains(ext) { return "video" }
|
||||
if ["pdf", "doc", "docx", "xls", "xlsx"].contains(ext) { return "file" }
|
||||
return "image"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
//
|
||||
// ScenicSelectionLocationProvider.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
/// 景区选择定位服务,负责向系统请求一次当前位置。
|
||||
final class ScenicSelectionLocationProvider: NSObject, CLLocationManagerDelegate {
|
||||
var onLocation: ((CLLocation) -> Void)?
|
||||
var onFailure: ((String) -> Void)?
|
||||
|
||||
private let manager = CLLocationManager()
|
||||
|
||||
/// 初始化定位服务并配置精度。
|
||||
override init() {
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
}
|
||||
|
||||
/// 请求一次当前位置;未授权时先触发系统授权弹窗。
|
||||
func request() {
|
||||
switch manager.authorizationStatus {
|
||||
case .notDetermined:
|
||||
manager.requestWhenInUseAuthorization()
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
manager.requestLocation()
|
||||
case .denied, .restricted:
|
||||
onFailure?("定位权限未开启,请在系统设置中允许访问位置")
|
||||
@unknown default:
|
||||
onFailure?("定位状态不可用")
|
||||
}
|
||||
}
|
||||
|
||||
/// 监听授权状态变化,并在授权后继续请求定位。
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
switch manager.authorizationStatus {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
manager.requestLocation()
|
||||
case .denied, .restricted:
|
||||
onFailure?("定位权限未开启,请在系统设置中允许访问位置")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// 接收系统返回的位置。
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
if let location = locations.last {
|
||||
onLocation?(location)
|
||||
}
|
||||
}
|
||||
|
||||
/// 接收系统定位错误。
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
onFailure?("定位失败:\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
//
|
||||
// ScenicSelectionViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 景区选择展示实体,表示列表中的一个可切换景区。
|
||||
struct ScenicSelectionItem: Equatable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let status: Int?
|
||||
let address: String
|
||||
let latitude: Double?
|
||||
let longitude: Double?
|
||||
let coverURLString: String?
|
||||
var isClosest: Bool
|
||||
var distanceMeters: CLLocationDistance?
|
||||
|
||||
/// 从业务作用域创建景区选择展示实体。
|
||||
init(scope: BusinessScope) {
|
||||
id = scope.id
|
||||
name = scope.name
|
||||
status = scope.status
|
||||
let trimmedAddress = scope.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
address = trimmedAddress.isEmpty ? "--" : trimmedAddress
|
||||
latitude = scope.latitude
|
||||
longitude = scope.longitude
|
||||
coverURLString = scope.coverURLString
|
||||
isClosest = false
|
||||
distanceMeters = nil
|
||||
}
|
||||
|
||||
/// 返回景区距离文案,无定位结果时显示占位。
|
||||
var distanceText: String {
|
||||
guard let distanceMeters else { return "--" }
|
||||
if distanceMeters < 1_000 {
|
||||
return "\(Int(distanceMeters.rounded()))m"
|
||||
}
|
||||
return String(format: "%.2fkm", distanceMeters / 1_000)
|
||||
}
|
||||
|
||||
/// 返回景区是否处于旧工程定义的暂未营业状态。
|
||||
var isClosed: Bool {
|
||||
status == 2
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景区选择 ViewModel,负责搜索、定位距离计算和切换景区持久化。
|
||||
final class ScenicSelectionViewModel {
|
||||
var searchQuery = ""
|
||||
var currentLocationText = "定位后获取最近景区"
|
||||
var locationWarning: String?
|
||||
private(set) var items: [ScenicSelectionItem] = []
|
||||
|
||||
/// 返回按搜索关键词过滤后的景区列表。
|
||||
var filteredItems: [ScenicSelectionItem] {
|
||||
let keyword = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !keyword.isEmpty else { return items }
|
||||
return items.filter { item in
|
||||
item.name.localizedCaseInsensitiveContains(keyword)
|
||||
|| item.address.localizedCaseInsensitiveContains(keyword)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从账号上下文重新加载可选景区,并优先把当前景区放在第一位。
|
||||
func reload(from accountContext: AccountContext) {
|
||||
let currentScenicId = accountContext.currentScenic?.id
|
||||
items = Self.makeItems(scopes: accountContext.scenicScopes, currentScenicId: currentScenicId)
|
||||
currentLocationText = items.first?.address ?? "定位后获取最近景区"
|
||||
locationWarning = nil
|
||||
}
|
||||
|
||||
/// 切换景区并把当前选择保存到账号快照。
|
||||
func select(
|
||||
scenicId: Int,
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
snapshotStore: AccountSnapshotStore
|
||||
) {
|
||||
accountContext.selectScenic(id: scenicId)
|
||||
snapshotStore.saveCurrentSelection(
|
||||
accountContext: accountContext,
|
||||
currentRoleId: permissionContext.currentRole?.id
|
||||
)
|
||||
}
|
||||
|
||||
/// 定位成功后计算距离,并标记距离最近景区。
|
||||
func applyCurrentLocation(latitude: Double, longitude: Double) {
|
||||
locationWarning = nil
|
||||
let current = CLLocation(latitude: latitude, longitude: longitude)
|
||||
for index in items.indices {
|
||||
guard let scenicLatitude = items[index].latitude,
|
||||
let scenicLongitude = items[index].longitude,
|
||||
scenicLatitude != 0 || scenicLongitude != 0
|
||||
else {
|
||||
items[index].distanceMeters = nil
|
||||
items[index].isClosest = false
|
||||
continue
|
||||
}
|
||||
let scenicLocation = CLLocation(latitude: scenicLatitude, longitude: scenicLongitude)
|
||||
items[index].distanceMeters = scenicLocation.distance(from: current)
|
||||
items[index].isClosest = false
|
||||
}
|
||||
if let closestIndex = items.indices
|
||||
.filter({ items[$0].distanceMeters != nil })
|
||||
.min(by: { (items[$0].distanceMeters ?? .greatestFiniteMagnitude) < (items[$1].distanceMeters ?? .greatestFiniteMagnitude) }) {
|
||||
items[closestIndex].isClosest = true
|
||||
currentLocationText = items[closestIndex].address
|
||||
} else if let first = items.first {
|
||||
items[0].isClosest = true
|
||||
currentLocationText = first.address
|
||||
}
|
||||
}
|
||||
|
||||
/// 定位失败时保留原列表并暴露提示文案。
|
||||
func applyLocationFailure(_ message: String) {
|
||||
locationWarning = message
|
||||
currentLocationText = items.first?.address ?? "定位后获取最近景区"
|
||||
if !items.isEmpty, !items.contains(where: \.isClosest) {
|
||||
items[0].isClosest = true
|
||||
}
|
||||
}
|
||||
|
||||
/// 从景区作用域生成展示列表,并把当前景区移动到首位。
|
||||
static func makeItems(scopes: [BusinessScope], currentScenicId: Int?) -> [ScenicSelectionItem] {
|
||||
var seen = Set<Int>()
|
||||
let uniqueItems = scopes.compactMap { scope -> ScenicSelectionItem? in
|
||||
guard scope.kind == .scenic, seen.insert(scope.id).inserted else { return nil }
|
||||
return ScenicSelectionItem(scope: scope)
|
||||
}
|
||||
var result: [ScenicSelectionItem]
|
||||
if let currentScenicId,
|
||||
let current = uniqueItems.first(where: { $0.id == currentScenicId }) {
|
||||
result = [current] + uniqueItems.filter { $0.id != currentScenicId }
|
||||
} else {
|
||||
result = uniqueItems
|
||||
}
|
||||
if !result.isEmpty {
|
||||
result[0].isClosest = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user