新增景区权限模块,集成 AMap CocoaPods 并支持模拟器

将景区选择与权限申请流程接入首页路由,集成高德 SDK 用于真机构建,并通过 Podfile post_install 钩子保证 arm64 模拟器可编译。

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

View File

@ -25,7 +25,7 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
`HomeMenuRouter` 将权限 URI 分为四类:
- `tab`:切换到订单或数据 Tab。
- `destination`:进入已接入的本地页面,如个人信息、景区选择、全部功能。
- `destination`:进入已接入的本地页面,如个人信息、景区选择、权限申请、全部功能。
- `unsupported`:已知 iOS 不支持的入口,进入占位说明。
- `placeholder`:未知或未迁移入口,记录诊断并进入占位说明。
@ -33,6 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
## 后续迁移
目前 `payment_collection``payment_qr``payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管`withdrawal_audit` 仍属于后台性质的提现审核管理模块,本轮保留占位,后续应作为独立功能迁移。
目前 `payment_collection``payment_qr``payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管`scenicselection``permission_apply``permission_apply_status``scenicapplication` 已由 `Features/ScenicPermission` 接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement``scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。

View File

@ -22,6 +22,9 @@ struct HomeMenuItem: Equatable, Identifiable {
enum HomeRoute: Hashable {
case profileSpace
case scenicSelection
case permissionApply
case permissionApplyStatus
case scenicApplication
case moreFunctions
case settings
case paymentCollection

View File

@ -81,6 +81,12 @@ enum HomeMenuRouter {
return .destination(.settings)
case "scenicselection":
return .destination(.scenicSelection)
case "permission_apply":
return .destination(.permissionApply)
case "permission_apply_status":
return .destination(.permissionApplyStatus)
case "scenicapplication":
return .destination(.scenicApplication)
case "wallet":
return .destination(.wallet)
case "payment_collection", "payment_qr", "payment_code":
@ -118,9 +124,6 @@ enum HomeMenuRouter {
"sample_upload",
"live_stream_management",
"live_album",
"scenicapplication",
"permission_apply",
"permission_apply_status",
"scenic_settlement",
"scenic_settlement_review",
"operating-area",

View File

@ -15,7 +15,13 @@ extension HomeRoute {
case .profileSpace:
ProfileView()
case .scenicSelection:
HomeScenicSelectionView()
ScenicSelectionView()
case .permissionApply:
PermissionApplyView()
case .permissionApplyStatus:
PermissionApplyStatusView()
case .scenicApplication:
ScenicApplicationView()
case .moreFunctions:
HomeMoreFunctionsView()
case .settings:
@ -58,31 +64,3 @@ struct HomeMigrationModuleView: View {
.navigationBarTitleDisplayMode(.inline)
}
}
///
struct HomeScenicSelectionView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(\.dismiss) private var dismiss
var body: some View {
List(accountContext.scenicScopes) { scenic in
Button {
accountContext.selectScenic(id: scenic.id)
dismiss()
} label: {
HStack {
Text(scenic.name)
.foregroundStyle(AppDesign.textPrimary)
Spacer()
if accountContext.currentScenic?.id == scenic.id {
Image(systemName: "checkmark")
.font(.system(size: AppMetrics.ControlSize.smallIcon, weight: .semibold))
.foregroundStyle(AppDesign.primary)
}
}
}
}
.navigationTitle("景区选择")
.navigationBarTitleDisplayMode(.inline)
}
}

View File

@ -0,0 +1,121 @@
//
// ScenicPermissionAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import Foundation
import Observation
///
@MainActor
protocol ScenicPermissionServing {
///
func scenicListAll() async throws -> ScenicListAllResponse
///
func areas() async throws -> [ScenicAreaNode]
///
func scenicApplicationPendingAll() async throws -> ScenicApplicationPendingsResponse
///
func scenicSubmit(_ request: ScenicApplicationSubmitRequest) async throws
///
func scenicApplicationUploadPlaceholder(_ items: [ScenicApplicationUploadPlaceholder]) async throws
///
func roleApplyAll() async throws -> [RoleApplyPendingResponse]
///
func roleApplySubmit(roleId: Int, scenicIds: [Int]) async throws
}
@MainActor
@Observable
/// API
final class ScenicPermissionAPI {
@ObservationIgnored private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func scenicListAll() async throws -> ScenicListAllResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/scenic/list-all"
)
)
}
///
func areas() async throws -> [ScenicAreaNode] {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/config/areas"
)
)
}
///
func scenicApplicationPendingAll() async throws -> ScenicApplicationPendingsResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/scenic-apply/all"
)
)
}
///
func scenicSubmit(_ request: ScenicApplicationSubmitRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/scenic-apply/submit",
body: request
)
)
}
///
func scenicApplicationUploadPlaceholder(_ items: [ScenicApplicationUploadPlaceholder]) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/scenic-apply/upload-placeholder",
body: ["files": items]
)
)
}
///
func roleApplyAll() async throws -> [RoleApplyPendingResponse] {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/role-apply/all"
)
)
}
///
func roleApplySubmit(roleId: Int, scenicIds: [Int]) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/role-apply/submit",
body: RoleApplySubmitRequest(scenicId: scenicIds, roleId: roleId)
)
)
}
}
extension ScenicPermissionAPI: ScenicPermissionServing {}

View File

@ -0,0 +1,336 @@
//
// ScenicPermissionModels.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import Foundation
///
struct ScenicAreaNode: Decodable, Equatable, Identifiable {
let id: String
let code: String
let name: String
let children: [ScenicAreaNode]
enum CodingKeys: String, CodingKey {
case id
case code
case name
case children
}
///
init(id: String, code: String = "", name: String, children: [ScenicAreaNode] = []) {
self.id = id
self.code = code
self.name = name
self.children = children
}
/// id/code
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decodeLossyString(forKey: .code)
let rawId = try container.decodeLossyString(forKey: .id).trimmingCharacters(in: .whitespacesAndNewlines)
id = rawId.isEmpty ? code : rawId
name = try container.decodeLossyString(forKey: .name)
children = try container.decodeIfPresent([ScenicAreaNode].self, forKey: .children) ?? []
}
}
///
struct ScenicApplicationPendingsResponse: Decodable, Equatable {
let items: [ScenicApplicationPendingResponse]
enum CodingKeys: String, CodingKey {
case items
case list
case data
}
///
init(items: [ScenicApplicationPendingResponse] = []) {
self.items = items
}
/// items/list/data
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
items = try container.decodeIfPresent([ScenicApplicationPendingResponse].self, forKey: .items)
?? container.decodeIfPresent([ScenicApplicationPendingResponse].self, forKey: .list)
?? container.decodeIfPresent([ScenicApplicationPendingResponse].self, forKey: .data)
?? []
}
}
///
struct ScenicApplicationPendingResponse: Decodable, Equatable, Identifiable {
let id: Int
let code: String
let scenicId: Int
let scenicName: String
let scenicImages: String
let scenicProvince: String
let scenicCity: String
let coopType: Int
let remark: String?
let status: Int
let rejectReason: String?
let auditedBy: String?
let auditedAt: String?
let auditNote: String?
let createdAt: String
enum CodingKeys: String, CodingKey {
case id
case code
case scenicId = "scenic_id"
case scenicName = "scenic_name"
case scenicImages = "scenic_images"
case scenicProvince = "scenic_province"
case scenicCity = "scenic_city"
case coopType = "coop_type"
case remark
case status
case rejectReason = "reject_reason"
case auditedBy = "audited_by"
case auditedAt = "audited_at"
case auditNote = "audit_note"
case createdAt = "created_at"
}
///
init(
id: Int,
code: String = "",
scenicId: Int = 0,
scenicName: String,
scenicImages: String = "",
scenicProvince: String = "",
scenicCity: String = "",
coopType: Int = 1,
remark: String? = nil,
status: Int = 1,
rejectReason: String? = nil,
auditedBy: String? = nil,
auditedAt: String? = nil,
auditNote: String? = nil,
createdAt: String = ""
) {
self.id = id
self.code = code
self.scenicId = scenicId
self.scenicName = scenicName
self.scenicImages = scenicImages
self.scenicProvince = scenicProvince
self.scenicCity = scenicCity
self.coopType = coopType
self.remark = remark
self.status = status
self.rejectReason = rejectReason
self.auditedBy = auditedBy
self.auditedAt = auditedAt
self.auditNote = auditNote
self.createdAt = createdAt
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
code = try container.decodeLossyString(forKey: .code)
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
scenicName = try container.decodeLossyString(forKey: .scenicName)
if let images = try? container.decode([String].self, forKey: .scenicImages) {
scenicImages = images.joined(separator: ",")
} else {
scenicImages = try container.decodeLossyString(forKey: .scenicImages)
}
scenicProvince = try container.decodeLossyString(forKey: .scenicProvince)
scenicCity = try container.decodeLossyString(forKey: .scenicCity)
coopType = try container.decodeLossyInt(forKey: .coopType) ?? 1
remark = try? container.decodeIfPresent(String.self, forKey: .remark)
status = try container.decodeLossyInt(forKey: .status) ?? 0
rejectReason = try? container.decodeIfPresent(String.self, forKey: .rejectReason)
auditedBy = try? container.decodeIfPresent(String.self, forKey: .auditedBy)
auditedAt = try? container.decodeIfPresent(String.self, forKey: .auditedAt)
auditNote = try? container.decodeIfPresent(String.self, forKey: .auditNote)
createdAt = try container.decodeLossyString(forKey: .createdAt)
}
}
///
struct ScenicApplicationSubmitRequest: Encodable, Equatable {
let scenicName: String?
let scenicImages: [String]?
let scenicProvince: String
let scenicCity: String
let coopType: Int
let remark: String
let scenicId: Int
enum CodingKeys: String, CodingKey {
case scenicName = "scenic_name"
case scenicImages = "scenic_images"
case scenicProvince = "scenic_province"
case scenicCity = "scenic_city"
case coopType = "coop_type"
case remark
case scenicId = "scenic_id"
}
}
/// /
struct RoleApplyPendingResponse: Decodable, Equatable, Hashable, Identifiable {
let id: Int
let code: String
let roleId: Int
let roleName: String
let scenicList: [RoleApplyScenicItem]
let status: Int
let statusLabel: String
let createdAt: String
let auditedBy: String?
let auditedAt: String?
let auditNote: String?
enum CodingKeys: String, CodingKey {
case id
case code
case roleId = "role_id"
case roleName = "role_name"
case scenicList = "scenic_list"
case status
case statusLabel = "status_label"
case createdAt = "created_at"
case auditedBy = "audited_by"
case auditedAt = "audited_at"
case auditNote = "audit_note"
}
///
init(
id: Int,
code: String = "",
roleId: Int,
roleName: String,
scenicList: [RoleApplyScenicItem] = [],
status: Int = 1,
statusLabel: String = "审核中",
createdAt: String = "",
auditedBy: String? = nil,
auditedAt: String? = nil,
auditNote: String? = nil
) {
self.id = id
self.code = code
self.roleId = roleId
self.roleName = roleName
self.scenicList = scenicList
self.status = status
self.statusLabel = statusLabel
self.createdAt = createdAt
self.auditedBy = auditedBy
self.auditedAt = auditedAt
self.auditNote = auditNote
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
code = try container.decodeLossyString(forKey: .code)
roleId = try container.decodeLossyInt(forKey: .roleId) ?? 0
roleName = try container.decodeLossyString(forKey: .roleName)
scenicList = try container.decodeIfPresent([RoleApplyScenicItem].self, forKey: .scenicList) ?? []
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
createdAt = try container.decodeLossyString(forKey: .createdAt)
auditedBy = try? container.decodeIfPresent(String.self, forKey: .auditedBy)
auditedAt = try? container.decodeIfPresent(String.self, forKey: .auditedAt)
auditNote = try? container.decodeIfPresent(String.self, forKey: .auditNote)
}
}
///
struct RoleApplyScenicItem: Decodable, Equatable, Hashable, Identifiable {
let id: Int
let name: String
enum CodingKeys: String, CodingKey {
case id
case name
}
///
init(id: Int, name: String) {
self.id = id
self.name = name
}
/// ID
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
name = try container.decodeLossyString(forKey: .name)
}
}
///
struct RoleApplySubmitRequest: Encodable, Equatable {
let scenicId: [Int]
let roleId: Int
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case roleId = "role_id"
}
}
///
struct ScenicApplicationUploadPlaceholder: Encodable, Equatable {
let fileName: String
let fileType: String
let fileSize: Int64
enum CodingKeys: String, CodingKey {
case fileName = "file_name"
case fileType = "file_type"
case fileSize = "file_size"
}
}
private extension KeyedDecodingContainer {
///
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "1" : "0"
}
return ""
}
///
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
return Int(value.trimmingCharacters(in: .whitespacesAndNewlines))
}
return nil
}
}

View File

@ -0,0 +1,29 @@
# 景区权限模块
## 模块职责
`Features/ScenicPermission` 承接首页 `scenicselection``permission_apply``permission_apply_status``scenicapplication` 权限入口,负责景区选择、角色景区权限申请、新增景区申请和申请状态展示。
## 代码结构
- `ScenicPermissionAPI`:封装景区列表、地区树、角色权限申请和景区申请接口。
- `ScenicSelectionViewModel`:管理景区搜索、定位距离、最近景区标记和切换景区持久化。
- `PermissionApplyViewModel`:管理申请角色、申请景区、多选状态、已有权限禁用和提交审核。
- `ScenicApplicationViewModel`:管理新增景区申请表单、图片上传、待审核记录回填和提交。
- `PermissionApplyStatusViewModel`:管理权限申请状态读取和驳回记录回填入口。
## 业务流程
景区选择页读取 `AccountContext.scenicScopes`,展示当前角色可访问景区。用户切换景区后调用 `AccountContext.selectScenic(id:)`,并通过 `AccountSnapshotStore.saveCurrentSelection` 保存当前景区、门店和角色 ID。`RootView` 会继续按当前景区 ID 懒加载景点/打卡点数据。
定位只用于计算景区距离和标记“距离最近”。定位结果不上传、不缓存;定位失败时页面保留景区列表并显示提示。
权限申请页读取 `PermissionContext.rolePermissions` 生成角色选项和已有权限展示。选择角色后请求景区列表,当前角色已拥有的景区禁用且不可提交。提交成功后等待后台审核。
新增景区申请页先加载地区树,再读取待审核、驳回或取消的景区申请记录。审核中的记录只读;驳回记录可编辑后重新提交。提交时先使用 `OSSUploadService.uploadScenicApplicationImage` 上传本地图片,再提交最终图片 URL 和表单数据。
## 缓存边界
当前景区、当前门店、角色 ID 和景区作用域属于账号快照,可保存到 UserDefaults。定位结果、景区申请图片临时数据、OSS STS token、上传进度和表单输入不落盘。
`scenic_settlement``scenic_settlement_review` 是首页独立结算业务入口,不属于本模块迁移范围,目前仍保持占位。

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

View File

@ -0,0 +1,189 @@
//
// PermissionApplyStatusView.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import SwiftUI
///
struct PermissionApplyStatusView: View {
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
@State private var viewModel = PermissionApplyStatusViewModel()
@State private var editingPending: RoleApplyPendingResponse?
let applyCode: String?
///
init(applyCode: String? = nil) {
self.applyCode = applyCode
}
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.small) {
if let pending = viewModel.pending {
statusCard(pending)
roleCard(pending)
scenicCard(pending)
} else if viewModel.loadFailed {
ContentUnavailableView(
"申请状态加载失败",
systemImage: "exclamationmark.triangle",
description: Text(viewModel.loadFailureReason ?? "请检查网络后重试")
)
Button("重新加载") {
Task { await viewModel.load(api: scenicPermissionAPI, applyCode: applyCode) }
}
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.buttonStyle(.plain)
} else {
ContentUnavailableView("暂无审核中的申请", systemImage: "doc.text.magnifyingglass")
}
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.navigationTitle("权限申请")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if let pending = viewModel.pending, pending.status == 3 {
ToolbarItem(placement: .topBarTrailing) {
Button {
editingPending = pending
} label: {
Label("去编辑", systemImage: "pencil")
}
}
}
}
.navigationDestination(item: $editingPending) { pending in
PermissionApplyView(initialPending: pending)
}
.task {
await viewModel.load(api: scenicPermissionAPI, applyCode: applyCode)
}
}
///
private func statusCard(_ pending: RoleApplyPendingResponse) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack {
Label(statusLabel(pending), systemImage: statusIcon(pending.status))
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(statusColor(pending.status))
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 28)
.background(statusColor(pending.status).opacity(0.12), in: Capsule())
Spacer()
}
row("申请编号", pending.code)
row("提交时间", pending.createdAt)
row("审核时间", pending.auditedAt ?? "--")
row("审核人", pending.auditedBy ?? "--")
row("审核备注", pending.auditNote ?? "--")
}
.padding(AppMetrics.Spacing.small)
.background(statusColor(pending.status).opacity(0.08), in: RoundedRectangle(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12).stroke(statusColor(pending.status).opacity(0.35), lineWidth: 1)
}
}
///
private func roleCard(_ pending: RoleApplyPendingResponse) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text("新增角色")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
Text(pending.roleName)
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.small)
.padding(.vertical, AppMetrics.Spacing.xSmall)
.background(AppDesign.primarySoft, in: Capsule())
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
///
private func scenicCard(_ pending: RoleApplyPendingResponse) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack {
Text("新增景区")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
Spacer()
Text("\(pending.scenicList.count)")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
if pending.scenicList.isEmpty {
Text("未选择景区")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 90), spacing: AppMetrics.Spacing.xSmall)], alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
ForEach(pending.scenicList) { scenic in
Text(scenic.name)
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.small)
.padding(.vertical, AppMetrics.Spacing.xSmall)
.background(AppDesign.primarySoft, in: Capsule())
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
private func row(_ title: String, _ value: String?) -> some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Text("\(title)")
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Text(nonEmpty(value) ?? "--")
.foregroundStyle(AppDesign.textPrimary)
.multilineTextAlignment(.trailing)
}
.font(.system(size: AppMetrics.FontSize.footnote))
}
private func statusLabel(_ pending: RoleApplyPendingResponse) -> String {
nonEmpty(pending.statusLabel) ?? {
switch pending.status {
case 2: return "已通过"
case 3: return "已驳回"
case 1: return "审核中"
default: return "未知状态"
}
}()
}
private func statusIcon(_ status: Int) -> String {
switch status {
case 2: return "checkmark.circle.fill"
case 3: return "xmark.octagon.fill"
case 1: return "clock.badge.exclamationmark.fill"
default: return "questionmark.circle.fill"
}
}
private func statusColor(_ status: Int) -> Color {
switch status {
case 2: return AppDesign.success
case 3: return Color(hex: 0xDC2626)
case 1: return AppDesign.warning
default: return AppDesign.textSecondary
}
}
///
private func nonEmpty(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@ -0,0 +1,414 @@
//
// PermissionApplyView.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import SwiftUI
///
struct PermissionApplyView: View {
@Environment(PermissionContext.self) private var permissionContext
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
@State private var viewModel: PermissionApplyViewModel
@State private var activePicker: PermissionPickerSheet?
///
init(initialPending: RoleApplyPendingResponse? = nil) {
_viewModel = State(initialValue: PermissionApplyViewModel(initialPending: initialPending))
}
var body: some View {
VStack(spacing: 0) {
NavigationLink(value: AppRoute.home(.scenicApplication)) {
NewScenicBanner()
}
.buttonStyle(.plain)
ScrollView {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.large) {
roleSection
scenicSection
if !viewModel.existingRoleInfos.isEmpty {
existingRoleSection
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
.padding(AppMetrics.Spacing.medium)
}
submitBar
}
.background(Color(hex: 0xF4F4F4).ignoresSafeArea())
.navigationTitle("申请权限")
.navigationBarTitleDisplayMode(.inline)
.task {
viewModel.bootstrap(rolePermissions: permissionContext.rolePermissions)
await viewModel.loadScenicListIfNeeded(api: scenicPermissionAPI)
}
.sheet(item: $activePicker) { picker in
PermissionPickerSheetView(
picker: picker,
roleOptions: viewModel.roleOptions,
selectedRoleId: viewModel.selectedRoleId,
scenicOptions: viewModel.scenicOptions,
selectedCount: viewModel.selectedCount,
onSelectRole: { role in
if viewModel.selectedRoleId != role.id {
viewModel.selectRole(id: role.id)
Task { await viewModel.loadScenicListIfNeeded(api: scenicPermissionAPI, force: true) }
}
activePicker = nil
},
onToggleScenic: { scenic in
viewModel.toggleScenic(id: scenic.id)
}
)
.presentationDetents([.medium, .large])
}
.alert("提示", isPresented: Binding(
get: { viewModel.message != nil },
set: { if !$0 { viewModel.message = nil } }
)) {
Button("知道了", role: .cancel) {}
} message: {
Text(viewModel.message ?? "")
}
}
private var roleSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack {
Text("角色信息")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
Spacer()
Text("单选")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Text("请选择您需要申请权限的角色")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.placeholder)
pickerDisplay(
title: viewModel.selectedRoleName ?? "请选择角色",
subtitle: "点击选择申请角色",
isPlaceholder: viewModel.selectedRoleId == nil,
isDisabled: viewModel.roleOptions.isEmpty
) {
guard !viewModel.roleOptions.isEmpty else { return }
activePicker = .role
}
if let notes = viewModel.selectedRoleNotes, !notes.isEmpty {
Text(notes)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
.padding(AppMetrics.Spacing.small)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 10))
}
}
}
private var scenicSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack {
Text("选择景区")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
Spacer()
Text("可多选")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Text("请选择您需要申请权限的景区")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.placeholder)
pickerDisplay(
title: viewModel.loadingScenics ? "加载景区中..." : viewModel.selectedScenicSummary,
subtitle: viewModel.selectedRoleId == nil ? "请先选择角色" : "点击选择一个或多个景区",
isPlaceholder: viewModel.selectedCount == 0,
isDisabled: viewModel.selectedRoleId == nil || viewModel.loadingScenics || viewModel.scenicOptions.isEmpty
) {
guard viewModel.selectedRoleId != nil else {
viewModel.message = "请先选择角色"
return
}
guard !viewModel.loadingScenics, !viewModel.scenicOptions.isEmpty else { return }
activePicker = .scenic
}
if viewModel.scenicLoadFailed && viewModel.scenicOptions.isEmpty {
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(AppDesign.warning)
Text("景区列表加载失败")
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Button("重新加载") {
Task { await viewModel.loadScenicListIfNeeded(api: scenicPermissionAPI, force: true) }
}
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.buttonStyle(.plain)
}
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xFFF7ED), in: RoundedRectangle(cornerRadius: 8))
}
}
}
private var existingRoleSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("已有角色信息")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
ForEach(viewModel.existingRoleInfos) { info in
ExistingRolePermissionRow(info: info)
}
}
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xF8F9FB), in: RoundedRectangle(cornerRadius: 12))
}
private var submitBar: some View {
Button(viewModel.submitting ? "提交中..." : "提交审核") {
Task { await viewModel.submit(api: scenicPermissionAPI) }
}
.disabled(!viewModel.canSubmit)
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.sheetButtonHeight)
.background(viewModel.canSubmit ? AppDesign.primary : Color(hex: 0xD8D8D8), in: RoundedRectangle(cornerRadius: 8))
.padding(AppMetrics.Spacing.medium)
.background(.white)
}
///
private func pickerDisplay(
title: String,
subtitle: String,
isPlaceholder: Bool,
isDisabled: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: AppMetrics.Spacing.small) {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: isPlaceholder ? .regular : .semibold))
.foregroundStyle(isPlaceholder ? AppDesign.placeholder : AppDesign.textPrimary)
.lineLimit(1)
Text(subtitle)
.font(.system(size: 11))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(1)
}
Spacer()
Image(systemName: "chevron.down")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(isDisabled ? Color(hex: 0xC7CDD6) : AppDesign.primary)
}
.padding(.horizontal, AppMetrics.Spacing.substantial)
.frame(height: 58)
.background(Color(hex: 0xFBFCFE), in: RoundedRectangle(cornerRadius: 10))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(isDisabled ? Color(hex: 0xE5E7EB) : AppDesign.primary.opacity(0.28), lineWidth: 1)
}
}
.buttonStyle(.plain)
.disabled(isDisabled)
}
}
///
private struct NewScenicBanner: View {
var body: some View {
HStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "plus.circle.fill")
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
.foregroundStyle(AppDesign.primary)
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
Text("没有找到想申请的景区?")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text("提交景区资料后等待平台审核")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textSecondary)
}
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(height: 64)
.background(.white)
}
}
///
private struct ExistingRolePermissionRow: View {
let info: ExistingRolePermissionInfo
@State private var expanded = true
var body: some View {
VStack(spacing: 0) {
Button {
withAnimation(.easeInOut(duration: 0.16)) {
expanded.toggle()
}
} label: {
HStack {
Text(info.name)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
Spacer()
Image(systemName: expanded ? "chevron.up" : "chevron.down")
.foregroundStyle(AppDesign.textSecondary)
}
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(height: 48)
}
.buttonStyle(.plain)
if expanded && !info.scenics.isEmpty {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 90), spacing: AppMetrics.Spacing.xSmall)], alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
ForEach(info.scenics.prefix(6)) { scenic in
Text(scenic.name)
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.success)
.lineLimit(1)
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 34)
.background(Color(hex: 0xF0FAEE), in: RoundedRectangle(cornerRadius: 4))
}
}
.padding(AppMetrics.Spacing.small)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(hex: 0xF8F9FB))
}
}
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
}
///
private enum PermissionPickerSheet: Identifiable {
case role
case scenic
var id: String {
switch self {
case .role: "role"
case .scenic: "scenic"
}
}
var title: String {
switch self {
case .role: "选择角色"
case .scenic: "选择景区"
}
}
}
///
private struct PermissionPickerSheetView: View {
@Environment(\.dismiss) private var dismiss
let picker: PermissionPickerSheet
let roleOptions: [PermissionRoleOption]
let selectedRoleId: Int?
let scenicOptions: [PermissionScenicOption]
let selectedCount: Int
let onSelectRole: (PermissionRoleOption) -> Void
let onToggleScenic: (PermissionScenicOption) -> Void
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: AppMetrics.Spacing.xSmall) {
switch picker {
case .role:
ForEach(roleOptions) { role in
roleRow(role)
}
case .scenic:
ForEach(scenicOptions) { scenic in
scenicRow(scenic)
}
}
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.navigationTitle(picker.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭") { dismiss() }
}
if picker == .scenic {
ToolbarItem(placement: .confirmationAction) {
Button("完成(\(selectedCount))") { dismiss() }
}
}
}
}
}
///
private func roleRow(_ role: PermissionRoleOption) -> some View {
Button { onSelectRole(role) } label: {
HStack(spacing: AppMetrics.Spacing.small) {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
Text(role.name)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
if !role.notes.isEmpty {
Text(role.notes)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(2)
}
}
Spacer()
Image(systemName: selectedRoleId == role.id ? "checkmark.circle.fill" : "circle")
.font(.system(size: AppMetrics.ControlSize.passwordIcon, weight: .semibold))
.foregroundStyle(selectedRoleId == role.id ? AppDesign.primary : Color(hex: 0xCBD5E1))
}
.padding(AppMetrics.Spacing.substantial)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
///
private func scenicRow(_ scenic: PermissionScenicOption) -> some View {
Button { onToggleScenic(scenic) } label: {
HStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: scenic.selected ? "checkmark.square.fill" : "square")
.font(.system(size: AppMetrics.ControlSize.passwordIcon, weight: .semibold))
.foregroundStyle(scenic.disabled ? Color(hex: 0xCBD5E1) : (scenic.selected ? AppDesign.primary : Color(hex: 0xCBD5E1)))
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
Text(scenic.name)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(scenic.disabled ? AppDesign.textSecondary : AppDesign.textPrimary)
.lineLimit(1)
if scenic.disabled {
Text("已有该角色权限")
.font(.system(size: 11))
.foregroundStyle(AppDesign.textSecondary)
}
}
Spacer()
}
.padding(AppMetrics.Spacing.substantial)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
.opacity(scenic.disabled ? 0.62 : 1)
}
.buttonStyle(.plain)
.disabled(scenic.disabled)
}
}

View File

@ -0,0 +1,532 @@
//
// ScenicApplicationView.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import PhotosUI
import SwiftUI
///
struct ScenicApplicationView: View {
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
@Environment(OSSUploadService.self) private var ossUploadService
@State private var viewModel = ScenicApplicationViewModel()
@State private var pickedImageItems: [PhotosPickerItem] = []
@State private var activeLocationPicker: ScenicLocationPicker?
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
if viewModel.loadFailed {
ContentUnavailableView(
"景区申请初始化失败",
systemImage: "exclamationmark.triangle",
description: Text(viewModel.loadFailureReason ?? "请检查网络后重试")
)
Button("重新加载") {
Task { await viewModel.loadInitial(api: scenicPermissionAPI) }
}
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.buttonStyle(.plain)
} else {
warmTip
formSection
}
}
.padding(AppMetrics.Spacing.medium)
}
if !viewModel.loadFailed {
agreementSection
}
}
.background(Color(hex: 0xF4F4F4).ignoresSafeArea())
.navigationTitle("景区申请")
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.loadInitial(api: scenicPermissionAPI)
}
.onChange(of: pickedImageItems) { _, items in
guard !items.isEmpty else { return }
Task {
await importPickedImages(items)
pickedImageItems = []
}
}
.sheet(item: $activeLocationPicker) { picker in
ScenicLocationSelectionSheet(
picker: picker,
provinces: viewModel.provinces,
cities: viewModel.cities,
selectedProvince: viewModel.selectedProvince,
selectedCity: viewModel.selectedCity,
onSelectProvince: { item in
viewModel.selectedProvince = item.name
viewModel.onProvinceChange()
activeLocationPicker = nil
},
onSelectCity: { item in
viewModel.selectedCity = item.name
activeLocationPicker = nil
}
)
.presentationDetents([.medium, .large])
}
.alert("提示", isPresented: Binding(
get: { viewModel.message != nil },
set: { if !$0 { viewModel.message = nil } }
)) {
Button("知道了", role: .cancel) {}
} message: {
Text(viewModel.message ?? "")
}
}
private var warmTip: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text("温馨提示")
.font(.system(size: AppMetrics.FontSize.body, weight: .bold))
Text("如果你想入驻更多的景区,平台可以帮你跟景区官方谈合作;如果已经有的景区,只要符合入驻条件即可申请。")
.font(.system(size: AppMetrics.FontSize.subheadline))
.lineSpacing(AppMetrics.LineSpacing.title)
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.medium)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
}
private var formSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.large) {
if let pending = viewModel.pending {
pendingStatusCard(pending)
}
if viewModel.pendingLoadFailed && viewModel.pending == nil {
pendingFailureRow
}
inputField("景区名称", text: $viewModel.scenicName, disabled: viewModel.isReadOnly)
uploadSection
locationSection
coopTypeSection
inputField("备注信息 (\(viewModel.remark.count)/50)", text: $viewModel.remark, axis: .vertical, disabled: viewModel.isReadOnly)
.onChange(of: viewModel.remark) { _, value in
if value.count > 50 {
viewModel.remark = String(value.prefix(50))
}
}
if let pending = viewModel.pending, pending.status == 3 {
Text("驳回原因:\(pending.auditNote ?? pending.rejectReason ?? "--")")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(Color(hex: 0xDC2626))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xFEF2F2), in: RoundedRectangle(cornerRadius: 8))
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
private var pendingFailureRow: some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(AppDesign.warning)
Text("待审核记录加载失败,暂未展示历史申请状态")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Button("重试") {
Task { await viewModel.reloadPending(api: scenicPermissionAPI) }
}
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.buttonStyle(.plain)
}
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xFFF7ED), in: RoundedRectangle(cornerRadius: 8))
}
private var uploadSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack {
Text("景区图片")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
Spacer()
Text("\(viewModel.uploadPlaceholderCount)/20")
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
}
LazyVGrid(columns: [GridItem(.adaptive(minimum: 92), spacing: AppMetrics.Spacing.xSmall)], spacing: AppMetrics.Spacing.xSmall) {
ForEach(viewModel.remoteImageURLs, id: \.self) { url in
scenicImageCell(url: url, local: nil, progress: nil) {
viewModel.removeRemoteImage(url)
}
}
ForEach(viewModel.localImages) { image in
scenicImageCell(url: image.uploadedURL, local: image.data, progress: image.uploading ? image.progress : nil) {
viewModel.removeLocalImage(id: image.id)
}
}
if viewModel.uploadPlaceholderCount < 20 && !viewModel.isReadOnly {
PhotosPicker(
selection: $pickedImageItems,
maxSelectionCount: max(0, 20 - viewModel.uploadPlaceholderCount),
matching: .images
) {
VStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: "plus")
.font(.system(size: AppMetrics.FontSize.title, weight: .semibold))
Text("上传图片")
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
}
.foregroundStyle(AppDesign.primary)
.frame(height: 92)
.frame(maxWidth: .infinity)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 10))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(AppDesign.primary.opacity(0.25), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
}
}
.buttonStyle(.plain)
}
}
}
}
private var locationSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text("景区位置")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
HStack(spacing: AppMetrics.Spacing.small) {
Button {
activeLocationPicker = .province
} label: {
pickerBox(title: viewModel.selectedProvince.isEmpty ? "省份" : viewModel.selectedProvince)
}
.buttonStyle(.plain)
.disabled(viewModel.isReadOnly)
Button {
guard !viewModel.selectedProvince.isEmpty else {
viewModel.message = "请先选择省份"
return
}
activeLocationPicker = .city
} label: {
pickerBox(title: viewModel.selectedCity.isEmpty ? "城市" : viewModel.selectedCity)
}
.buttonStyle(.plain)
.disabled(viewModel.isReadOnly)
}
}
}
private var coopTypeSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("合作类型")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
VStack(spacing: AppMetrics.Spacing.xSmall) {
coopButton(title: "我是摄影师,我想入驻", tag: 1)
coopButton(title: "我是景区资源方,可以合作", tag: 2)
coopButton(title: "我有摄影师团队,带队入驻", tag: 3)
coopButton(title: "我是旅拍店,想合作", tag: 4)
coopButton(title: "我已经在景区运营旅拍了,想使用软件,提高效率", tag: 5)
}
}
}
private var agreementSection: some View {
VStack(spacing: AppMetrics.Spacing.small) {
HStack(spacing: AppMetrics.Spacing.small) {
Button {
viewModel.agreed.toggle()
} label: {
Image(systemName: viewModel.agreed ? "checkmark.square.fill" : "square")
.font(.system(size: AppMetrics.ControlSize.passwordIcon))
.foregroundStyle(viewModel.agreed ? AppDesign.primary : AppDesign.textSecondary)
}
.buttonStyle(.plain)
.disabled(viewModel.isReadOnly)
Text("已阅读并同意《用户须知》与《隐私政策》")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(2)
Spacer()
}
Button(viewModel.submitting ? "提交中..." : "确认提交") {
Task { await viewModel.submit(api: scenicPermissionAPI, uploader: ossUploadService) }
}
.disabled(viewModel.isSubmitLocked)
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 46)
.background(viewModel.canSubmit ? AppDesign.primary : Color(hex: 0xD8D8D8), in: RoundedRectangle(cornerRadius: 8))
}
.padding(AppMetrics.Spacing.medium)
.background(.white)
}
///
private func inputField(_ title: String, text: Binding<String>, axis: Axis = .horizontal, disabled: Bool = false) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text(title)
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
TextField("", text: text, prompt: Text("请输入\(title)").foregroundColor(AppDesign.placeholder), axis: axis)
.disabled(disabled)
.font(.system(size: AppMetrics.FontSize.subheadline))
.lineLimit(axis == .vertical ? 3...5 : 1...1)
.appInputFieldStyle(cornerRadius: 8, minHeight: axis == .vertical ? 100 : 50)
}
}
///
private func pickerBox(title: String) -> some View {
HStack {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(title == "省份" || title == "城市" ? AppDesign.placeholder : AppDesign.textPrimary)
.lineLimit(1)
Spacer()
Image(systemName: "chevron.down")
.foregroundStyle(AppDesign.textSecondary)
}
.padding(.horizontal, AppMetrics.Spacing.substantial)
.frame(height: 50)
.background(Color(hex: 0xFBFCFE), in: RoundedRectangle(cornerRadius: 10))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(Color(hex: 0xE2E8F0), lineWidth: 1)
}
}
///
private func coopButton(title: String, tag: Int) -> some View {
Button {
viewModel.coopType = tag
} label: {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(viewModel.coopType == tag ? .white : AppDesign.textSecondary)
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(minHeight: tag == 5 ? 70 : 48)
.background(viewModel.coopType == tag ? AppDesign.primary : .white, in: RoundedRectangle(cornerRadius: 8))
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(viewModel.coopType == tag ? AppDesign.primary : Color(hex: 0xE2E8F0), lineWidth: 1)
}
}
.buttonStyle(.plain)
.disabled(viewModel.isReadOnly)
}
///
private func pendingStatusCard(_ pending: ScenicApplicationPendingResponse) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Label(statusText(pending.status), systemImage: statusIcon(pending.status))
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(statusColor(pending.status))
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 28)
.background(statusColor(pending.status).opacity(0.12), in: Capsule())
metaRow("申请编号", pending.code.isEmpty ? "--" : pending.code)
metaRow("提交时间", pending.createdAt.isEmpty ? "--" : pending.createdAt)
}
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
}
///
private func scenicImageCell(url: String?, local data: Data?, progress: Int?, onDelete: @escaping () -> Void) -> some View {
ZStack(alignment: .topTrailing) {
ZStack {
if let data, let image = UIImage(data: data) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else if let url, !url.isEmpty {
RemoteImage(urlString: url) {
imagePlaceholder
}
} else {
imagePlaceholder
}
if let progress {
Color.black.opacity(0.38)
ProgressView(value: Double(progress), total: 100)
.tint(.white)
.padding(AppMetrics.Spacing.xSmall)
}
}
.frame(height: 92)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10))
if !viewModel.isReadOnly {
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: AppMetrics.FontSize.title2, weight: .semibold))
.foregroundStyle(.white, Color.black.opacity(0.55))
.padding(AppMetrics.Spacing.xxSmall)
}
.buttonStyle(.plain)
}
}
}
private var imagePlaceholder: some View {
ZStack {
Color(hex: 0xF1F5F9)
Image(systemName: "photo.fill")
.font(.system(size: AppMetrics.FontSize.title, weight: .semibold))
.foregroundStyle(Color(hex: 0x94A3B8))
}
}
///
private func importPickedImages(_ items: [PhotosPickerItem]) async {
for item in items {
guard viewModel.uploadPlaceholderCount < 20 else { return }
do {
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
viewModel.addLocalImage(data: data, fileName: "scenic_\(UUID().uuidString).jpg")
} catch {
viewModel.message = error.localizedDescription
}
}
}
private func metaRow(_ title: String, _ value: String) -> some View {
HStack {
Text("\(title)")
.foregroundStyle(AppDesign.textSecondary)
Text(value)
.foregroundStyle(AppDesign.textPrimary)
Spacer()
}
.font(.system(size: AppMetrics.FontSize.caption))
}
private func statusText(_ status: Int) -> String {
switch status {
case 1: return "待审核"
case 2: return "已通过"
case 3: return "已驳回"
case 9: return "已取消"
default: return "未提交"
}
}
private func statusIcon(_ status: Int) -> String {
switch status {
case 2: return "checkmark.circle.fill"
case 3: return "xmark.octagon.fill"
case 1: return "clock.badge.exclamationmark.fill"
default: return "questionmark.circle.fill"
}
}
private func statusColor(_ status: Int) -> Color {
switch status {
case 2: return AppDesign.success
case 3: return Color(hex: 0xDC2626)
case 1: return AppDesign.warning
default: return AppDesign.textSecondary
}
}
}
///
private enum ScenicLocationPicker: Identifiable {
case province
case city
var id: String {
switch self {
case .province: "province"
case .city: "city"
}
}
var title: String {
switch self {
case .province: "选择省份"
case .city: "选择城市"
}
}
}
///
private struct ScenicLocationSelectionSheet: View {
@Environment(\.dismiss) private var dismiss
let picker: ScenicLocationPicker
let provinces: [ScenicAreaNode]
let cities: [ScenicAreaNode]
let selectedProvince: String
let selectedCity: String
let onSelectProvince: (ScenicAreaNode) -> Void
let onSelectCity: (ScenicAreaNode) -> Void
private var items: [ScenicAreaNode] {
picker == .province ? provinces : cities
}
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: AppMetrics.Spacing.xSmall) {
ForEach(items) { item in
Button {
switch picker {
case .province:
onSelectProvince(item)
case .city:
onSelectCity(item)
}
dismiss()
} label: {
HStack(spacing: AppMetrics.Spacing.small) {
Text(item.name)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: isSelected(item) ? .semibold : .regular))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
if isSelected(item) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(AppDesign.primary)
}
}
.padding(.horizontal, AppMetrics.Spacing.substantial)
.frame(height: 48)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.navigationTitle(picker.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭") { dismiss() }
}
}
}
}
///
private func isSelected(_ item: ScenicAreaNode) -> Bool {
switch picker {
case .province:
item.name == selectedProvince
case .city:
item.name == selectedCity
}
}
}

View File

@ -0,0 +1,243 @@
//
// ScenicSelectionView.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import CoreLocation
import SwiftUI
///
struct ScenicSelectionView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(PermissionContext.self) private var permissionContext
@Environment(AccountSnapshotStore.self) private var snapshotStore
@Environment(\.dismiss) private var dismiss
@State private var viewModel = ScenicSelectionViewModel()
@State private var locationProvider = ScenicSelectionLocationProvider()
var body: some View {
VStack(spacing: 0) {
applyBanner
searchBar
currentLocationBar
content
}
.background(AppDesign.backgroundColor.ignoresSafeArea())
.navigationTitle("景区选择")
.navigationBarTitleDisplayMode(.inline)
.task {
viewModel.reload(from: accountContext)
requestLocation()
}
}
private var applyBanner: some View {
NavigationLink(value: AppRoute.home(.permissionApply)) {
HStack(spacing: 10) {
Image(systemName: "building.2.fill")
.font(.system(size: AppMetrics.ControlSize.checkboxIcon, weight: .semibold))
.foregroundStyle(AppDesign.warning)
Text("希望开通更多景区权限")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(AppDesign.warning)
.lineLimit(1)
Spacer()
Text("立即申请")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
Image(systemName: "chevron.right")
.font(.system(size: AppMetrics.FontSize.caption, weight: .bold))
}
.foregroundStyle(AppDesign.warning)
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(height: 56)
.background(Color(hex: 0xFFF0E2))
}
.buttonStyle(.plain)
}
private var searchBar: some View {
HStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "magnifyingglass")
.font(.system(size: AppMetrics.ControlSize.checkboxIcon))
.foregroundStyle(AppDesign.placeholder)
TextField("搜索景区", text: $viewModel.searchQuery)
.font(.system(size: AppMetrics.FontSize.subheadline))
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.tint(AppDesign.primary)
}
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(height: AppMetrics.ControlSize.inputHeight)
.background(Color(hex: 0xF2F4F8), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
.padding(.horizontal, AppMetrics.Spacing.medium)
.padding(.top, AppMetrics.Spacing.medium)
}
private var currentLocationBar: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: "location.fill")
.font(.system(size: AppMetrics.ControlSize.smallIcon, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text(viewModel.currentLocationText)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
Button("当前定位地址") {
requestLocation()
}
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(AppDesign.primary)
.buttonStyle(.plain)
}
if let warning = viewModel.locationWarning {
Text(warning)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.warning)
.lineLimit(2)
}
}
.padding(.horizontal, AppMetrics.Spacing.medium)
.padding(.vertical, AppMetrics.Spacing.small)
}
@ViewBuilder
private var content: some View {
ScrollView {
if viewModel.filteredItems.isEmpty {
ContentUnavailableView(
"暂无可选景区",
systemImage: "mountain.2",
description: Text("可尝试清空搜索关键词或刷新定位。")
)
.frame(maxWidth: .infinity, minHeight: 240)
.padding(.top, AppMetrics.Spacing.xLarge)
} else {
LazyVStack(spacing: AppMetrics.Spacing.small) {
ForEach(viewModel.filteredItems) { scenic in
ScenicSelectionCard(
scenic: scenic,
isSelected: accountContext.currentScenic?.id == scenic.id
) {
viewModel.select(
scenicId: scenic.id,
accountContext: accountContext,
permissionContext: permissionContext,
snapshotStore: snapshotStore
)
dismiss()
}
}
}
.padding(.horizontal, AppMetrics.Spacing.large)
.padding(.bottom, AppMetrics.Spacing.xLarge)
}
}
}
/// ViewModel
private func requestLocation() {
viewModel.currentLocationText = "定位中..."
viewModel.locationWarning = nil
locationProvider.onLocation = { location in
Task { @MainActor in
viewModel.applyCurrentLocation(
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude
)
}
}
locationProvider.onFailure = { message in
Task { @MainActor in
viewModel.applyLocationFailure(message)
}
}
locationProvider.request()
}
}
///
private struct ScenicSelectionCard: View {
let scenic: ScenicSelectionItem
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: AppMetrics.Spacing.small) {
RemoteImage(urlString: scenic.coverURLString) {
ZStack {
Color(hex: 0xF0F0F0)
Image(systemName: "photo")
.font(.system(size: AppMetrics.FontSize.title, weight: .medium))
.foregroundStyle(Color(hex: 0xAAB2BD))
}
}
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Text(scenic.name)
.font(.system(size: AppMetrics.FontSize.body, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
statusBadge
}
HStack(spacing: AppMetrics.Spacing.xxSmall) {
Text(scenic.distanceText)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
.fixedSize()
Text(scenic.address)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(1)
}
if isSelected {
Label("当前景区", systemImage: "checkmark.circle.fill")
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(AppDesign.primary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(AppMetrics.Spacing.small)
.frame(minHeight: 104)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(isSelected ? AppDesign.primary : Color(hex: 0xEEF2F7), lineWidth: isSelected ? 2 : 1)
}
.shadow(color: .black.opacity(isSelected ? 0.08 : 0.03), radius: 8, x: 0, y: 3)
}
.buttonStyle(.plain)
}
@ViewBuilder
private var statusBadge: some View {
if scenic.isClosest {
Text("距离最近")
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, AppMetrics.Spacing.xSmall)
.frame(height: 26)
.background(Color(hex: 0xE74C3C), in: RoundedRectangle(cornerRadius: 4))
} else if scenic.isClosed {
Text("暂未营业")
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
.padding(.horizontal, AppMetrics.Spacing.xSmall)
.frame(height: 26)
.background(Color(hex: 0xE0E0E0), in: RoundedRectangle(cornerRadius: 4))
}
}
}
private extension AppDesign {
static let backgroundColor = Color(hex: 0xF5F7FA)
}