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