Add ScenicPermission module and AMap CocoaPods with simulator support.

Integrate scenic selection and permission flows into home routing, add AMap SDK for device builds while keeping arm64 simulators compilable via Podfile post_install hooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 10:15:26 +08:00
parent e8bc9b7f44
commit c5374666de
416 changed files with 18641 additions and 38 deletions

View File

@ -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
}
}
}

View File

@ -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"
}
}

View File

@ -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)")
}
}

View File

@ -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
}
}