Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,119 @@
|
||||
//
|
||||
// ScenicPermissionAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 景区权限模块服务协议,定义景区选择、权限申请和景区申请所需接口。
|
||||
@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
|
||||
/// 景区权限 API,封装景区选择和申请相关网络请求。
|
||||
final class ScenicPermissionAPI {
|
||||
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_ios/Features/ScenicPermission/ScenicPermission.md
Normal file
29
suixinkan_ios/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` 是首页独立结算业务入口,已由 `Features/ScenicSettlement` 接管。ScenicPermission 模块只提供景区列表、景区申请记录和权限申请记录等可复用接口。
|
||||
@ -0,0 +1,216 @@
|
||||
//
|
||||
// ScenicPermissionViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 景区选择页。
|
||||
final class ScenicSelectionViewController: ModuleTableViewController {
|
||||
private let viewModel = ScenicSelectionViewModel()
|
||||
private let searchBar = UISearchBar()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "选择景区"
|
||||
super.viewDidLoad()
|
||||
setupSearchBar()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
private func setupSearchBar() {
|
||||
searchBar.placeholder = "搜索景区"
|
||||
searchBar.delegate = self
|
||||
searchBar.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 56)
|
||||
tableView.tableHeaderView = searchBar
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.filteredItems.count
|
||||
}
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.filteredItems[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.address, detail: item.distanceText)
|
||||
}
|
||||
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.filteredItems[indexPath.row]
|
||||
viewModel.select(
|
||||
scenicId: item.id,
|
||||
accountContext: services.accountContext,
|
||||
permissionContext: services.permissionContext,
|
||||
snapshotStore: AccountSnapshotStore()
|
||||
)
|
||||
navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
viewModel.reload(from: services.accountContext)
|
||||
}
|
||||
}
|
||||
|
||||
extension ScenicSelectionViewModel: ViewModelBindable {}
|
||||
|
||||
extension ScenicSelectionViewController: UISearchBarDelegate {
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
viewModel.searchQuery = searchText
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限申请页。
|
||||
final class PermissionApplyViewController: ModuleTableViewController {
|
||||
private let viewModel = PermissionApplyViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "权限申请"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "提交",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(submit)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? viewModel.roleOptions.count : viewModel.scenicOptions.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 0 ? "选择角色" : "选择景区"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TitleSubtitleTableViewCell
|
||||
if indexPath.section == 0 {
|
||||
let role = viewModel.roleOptions[indexPath.row]
|
||||
cell.configure(title: role.name, subtitle: role.notes)
|
||||
cell.accessoryType = viewModel.selectedRoleId == role.id ? .checkmark : .none
|
||||
} else {
|
||||
let scenic = viewModel.scenicOptions[indexPath.row]
|
||||
cell.configure(title: scenic.name, subtitle: scenic.disabled ? "已有权限" : nil)
|
||||
cell.accessoryType = scenic.selected ? .checkmark : .none
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if indexPath.section == 0 {
|
||||
viewModel.selectRole(id: viewModel.roleOptions[indexPath.row].id)
|
||||
Task { await viewModel.loadScenicListIfNeeded(api: services.scenicPermissionAPI, force: true) }
|
||||
} else {
|
||||
viewModel.toggleScenic(id: viewModel.scenicOptions[indexPath.row].id)
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
viewModel.bootstrap(rolePermissions: services.permissionContext.rolePermissions)
|
||||
if viewModel.selectedRoleId != nil {
|
||||
await viewModel.loadScenicListIfNeeded(api: services.scenicPermissionAPI, force: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func submit() {
|
||||
Task {
|
||||
await viewModel.submit(api: services.scenicPermissionAPI)
|
||||
if viewModel.message == "提交成功,等待审核" {
|
||||
services.toastCenter.show(viewModel.message ?? "提交成功")
|
||||
navigationController?.popViewController(animated: true)
|
||||
} else if let message = viewModel.message {
|
||||
services.toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PermissionApplyViewModel: ViewModelBindable {}
|
||||
|
||||
/// 权限申请状态页。
|
||||
final class PermissionApplyStatusViewController: ModuleTableViewController {
|
||||
private let viewModel = PermissionApplyStatusViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "申请状态"
|
||||
super.viewDidLoad()
|
||||
viewModel.onChange = { [weak self] in self?.reloadTable() }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.pending == nil ? 0 : 4
|
||||
}
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
guard let pending = viewModel.pending else { return }
|
||||
switch indexPath.row {
|
||||
case 0: cell.configure(title: "申请编号", subtitle: pending.code)
|
||||
case 1: cell.configure(title: "角色", subtitle: pending.roleName)
|
||||
case 2: cell.configure(title: "状态", subtitle: pending.statusLabel)
|
||||
default: cell.configure(title: "申请时间", subtitle: pending.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.load(api: services.scenicPermissionAPI, applyCode: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区申请页。
|
||||
final class ScenicApplicationViewController: ModuleTableViewController {
|
||||
private let viewModel = ScenicApplicationViewModel()
|
||||
private let nameField = UITextField()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "景区申请"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "提交",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(submit)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
nameField.placeholder = "景区名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
|
||||
tableView.tableHeaderView = nameField
|
||||
wireViewModel(viewModel) { [weak self] in
|
||||
if self?.nameField.text?.isEmpty != false {
|
||||
self?.nameField.text = self?.viewModel.scenicName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { 3 }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
switch indexPath.row {
|
||||
case 0: cell.configure(title: "省份", subtitle: viewModel.selectedProvince)
|
||||
case 1: cell.configure(title: "城市", subtitle: viewModel.selectedCity)
|
||||
default: cell.configure(title: "合作类型", subtitle: viewModel.coopType == 1 ? "自营" : "合作")
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadInitial(api: services.scenicPermissionAPI)
|
||||
}
|
||||
|
||||
@objc private func submit() {
|
||||
viewModel.scenicName = nameField.text ?? ""
|
||||
Task {
|
||||
await viewModel.submit(api: services.scenicPermissionAPI, uploader: services.ossUploadService)
|
||||
if let message = viewModel.message {
|
||||
services.toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ScenicApplicationViewModel: ViewModelBindable {}
|
||||
@ -0,0 +1,211 @@
|
||||
//
|
||||
// PermissionApplyViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 权限申请角色选项实体,表示可申请的一个业务角色。
|
||||
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
|
||||
/// 权限申请 ViewModel,负责角色/景区选择和提交审核。
|
||||
final class PermissionApplyViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var roleOptions: [PermissionRoleOption] = [] { didSet { onChange?() } }
|
||||
var selectedRoleId: Int? { didSet { onChange?() } }
|
||||
var scenicOptions: [PermissionScenicOption] = [] { didSet { onChange?() } }
|
||||
var loadingScenics = false { didSet { onChange?() } }
|
||||
var scenicLoadFailed = false { didSet { onChange?() } }
|
||||
var scenicLoadFailureReason: String? { didSet { onChange?() } }
|
||||
var submitting = false { didSet { onChange?() } }
|
||||
var message: String? { didSet { onChange?() } }
|
||||
|
||||
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
|
||||
/// 权限申请状态 ViewModel,负责读取审核中的或指定编号的权限申请。
|
||||
final class PermissionApplyStatusViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var loading = false { didSet { onChange?() } }
|
||||
var pending: RoleApplyPendingResponse? { didSet { onChange?() } }
|
||||
var loadFailed = false { didSet { onChange?() } }
|
||||
var loadFailureReason: String? { didSet { onChange?() } }
|
||||
|
||||
/// 加载权限申请状态;有申请编号时优先匹配编号,否则展示审核中或驳回记录。
|
||||
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,316 @@
|
||||
//
|
||||
// ScenicApplicationViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 景区申请本地图片草稿实体,表示尚未或正在上传的图片。
|
||||
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
|
||||
/// 景区申请 ViewModel,负责新增景区申请表单、图片上传和待审核记录回填。
|
||||
final class ScenicApplicationViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var scenicName = "" { didSet { onChange?() } }
|
||||
var imageURLs = "" { didSet { onChange?() } }
|
||||
var localImages: [ScenicApplicationImageDraft] = [] { didSet { onChange?() } }
|
||||
var selectedProvince = "" { didSet { onChange?() } }
|
||||
var selectedCity = "" { didSet { onChange?() } }
|
||||
var coopType = 1 { didSet { onChange?() } }
|
||||
var remark = "" { didSet { onChange?() } }
|
||||
var agreed = false { didSet { onChange?() } }
|
||||
var submitting = false { didSet { onChange?() } }
|
||||
var message: String? { didSet { onChange?() } }
|
||||
var provinces: [ScenicAreaNode] = [] { didSet { onChange?() } }
|
||||
var cities: [ScenicAreaNode] = [] { didSet { onChange?() } }
|
||||
var pending: ScenicApplicationPendingResponse? { didSet { onChange?() } }
|
||||
var loading = false { didSet { onChange?() } }
|
||||
var loadFailed = false { didSet { onChange?() } }
|
||||
var loadFailureReason: String? { didSet { onChange?() } }
|
||||
var pendingLoadFailed = false { didSet { onChange?() } }
|
||||
var pendingLoadFailureReason: String? { didSet { onChange?() } }
|
||||
|
||||
/// 返回当前申请是否只读;审核中的申请不可再次编辑提交。
|
||||
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,149 @@
|
||||
//
|
||||
// ScenicSelectionViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
/// 景区选择展示实体,表示列表中的一个可切换景区。
|
||||
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
|
||||
/// 景区选择 ViewModel,负责搜索、定位距离计算和切换景区持久化。
|
||||
final class ScenicSelectionViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var searchQuery = "" { didSet { onChange?() } }
|
||||
var currentLocationText = "定位后获取最近景区" { didSet { onChange?() } }
|
||||
var locationWarning: String? { didSet { onChange?() } }
|
||||
private(set) var items: [ScenicSelectionItem] = [] { didSet { onChange?() } }
|
||||
|
||||
/// 返回按搜索关键词过滤后的景区列表。
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user