新增景区权限模块,集成 AMap CocoaPods 并支持模拟器
将景区选择与权限申请流程接入首页路由,集成高德 SDK 用于真机构建,并通过 Podfile post_install 钩子保证 arm64 模拟器可编译。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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