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,36 @@
//
// AMapBootstrap.swift
// suixinkan
//
// SDK AMAP_ENABLED AMap
//
import Foundation
#if AMAP_ENABLED
import AMapFoundationKit
import AMapLocationKit
import AMapSearchKit
import MAMapKit
enum AMapBootstrap {
/// App `apiKey` Key
static func configure(apiKey: String) {
guard !apiKey.isEmpty else { return }
AMapServices.shared().apiKey = apiKey
AMapServices.shared().enableHTTPS = true
AMapSearchAPI.updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapSearchAPI.updatePrivacyAgree(.didAgree)
MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain)
MAMapView.updatePrivacyAgree(.didAgree)
AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapLocationManager.updatePrivacyAgree(.didAgree)
}
}
#else
enum AMapBootstrap {
static func configure(apiKey: String) {}
}
#endif

View File

@ -15,6 +15,7 @@ struct RootView: View {
@State private var scenicSpotContext = ScenicSpotContext()
@State private var appRouter = AppRouter()
@State private var toastCenter = ToastCenter()
@State private var snapshotStore: AccountSnapshotStore
@State private var apiClient: APIClient
@State private var authAPI: AuthAPI
@State private var profileAPI: ProfileAPI
@ -25,6 +26,7 @@ struct RootView: View {
@State private var statisticsAPI: StatisticsAPI
@State private var paymentAPI: PaymentAPI
@State private var walletAPI: WalletAPI
@State private var scenicPermissionAPI: ScenicPermissionAPI
@State private var authSessionCoordinator: AuthSessionCoordinator
@State private var sessionBootstrapper: SessionBootstrapper
@ -33,6 +35,7 @@ struct RootView: View {
let apiClient = APIClient()
let tokenStore = SessionTokenStore()
let snapshotStore = AccountSnapshotStore()
_snapshotStore = State(initialValue: snapshotStore)
_apiClient = State(initialValue: apiClient)
_authAPI = State(initialValue: AuthAPI(client: apiClient))
_profileAPI = State(initialValue: ProfileAPI(client: apiClient))
@ -44,6 +47,7 @@ struct RootView: View {
_statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient))
_paymentAPI = State(initialValue: PaymentAPI(client: apiClient))
_walletAPI = State(initialValue: WalletAPI(client: apiClient))
_scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient))
_authSessionCoordinator = State(
initialValue: AuthSessionCoordinator(
tokenStore: tokenStore,
@ -68,6 +72,7 @@ struct RootView: View {
.environment(scenicSpotContext)
.environment(appRouter)
.environment(toastCenter)
.environment(snapshotStore)
.environment(apiClient)
.environment(authAPI)
.environment(profileAPI)
@ -78,6 +83,7 @@ struct RootView: View {
.environment(statisticsAPI)
.environment(paymentAPI)
.environment(walletAPI)
.environment(scenicPermissionAPI)
.environment(authSessionCoordinator)
.task {
apiClient.bindAuthTokenProvider { appSession.token }

View File

@ -27,13 +27,33 @@ struct BusinessScope: Codable, Equatable, Identifiable {
var name: String
var kind: Kind
var parentScenicId: Int?
var status: Int?
var address: String?
var latitude: Double?
var longitude: Double?
var coverURLString: String?
/// parentScenicId
init(id: Int, name: String, kind: Kind, parentScenicId: Int? = nil) {
init(
id: Int,
name: String,
kind: Kind,
parentScenicId: Int? = nil,
status: Int? = nil,
address: String? = nil,
latitude: Double? = nil,
longitude: Double? = nil,
coverURLString: String? = nil
) {
self.id = id
self.name = name
self.kind = kind
self.parentScenicId = parentScenicId
self.status = status
self.address = address
self.latitude = latitude
self.longitude = longitude
self.coverURLString = coverURLString
}
}

View File

@ -77,7 +77,16 @@ final class PermissionContext {
var seen = Set<Int>()
return scenics.compactMap { scenic in
guard seen.insert(scenic.id).inserted else { return nil }
return BusinessScope(id: scenic.id, name: scenic.name, kind: .scenic)
return BusinessScope(
id: scenic.id,
name: scenic.name,
kind: .scenic,
status: scenic.status,
address: scenic.location?.address,
latitude: scenic.location?.lat,
longitude: scenic.location?.lng,
coverURLString: scenic.coverImg
)
}
}
}

View File

@ -30,6 +30,7 @@ final class AppMetrics {
static let xxSmall: CGFloat = 4
static let xSmall: CGFloat = 8
static let small: CGFloat = 12
static let substantial: CGFloat = 14
static let medium: CGFloat = 16
static let mediumLarge: CGFloat = 18
static let large: CGFloat = 20

View File

@ -83,4 +83,25 @@ final class AccountSnapshotStore {
func clear() {
defaults.removeObject(forKey: key)
}
///
@MainActor
func saveCurrentSelection(
accountContext: AccountContext,
currentRoleId: Int?
) {
let existing = load()
save(
AccountSnapshot(
profile: accountContext.profile ?? existing?.profile,
accountType: existing?.accountType,
businessUserId: existing?.businessUserId,
currentRoleId: currentRoleId ?? existing?.currentRoleId,
scenicScopes: accountContext.scenicScopes,
storeScopes: accountContext.storeScopes,
currentScenicId: accountContext.currentScenic?.id,
currentStoreId: accountContext.currentStore?.id
)
)
}
}

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