Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,62 @@
//
// ScenicSelectionLocationProvider.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import CoreLocation
import Foundation
///
final class ScenicSelectionLocationProvider: NSObject, CLLocationManagerDelegate {
var onLocation: ((CLLocation) -> Void)?
var onFailure: ((String) -> Void)?
private let manager = CLLocationManager()
///
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
}
///
func request() {
switch manager.authorizationStatus {
case .notDetermined:
manager.requestWhenInUseAuthorization()
case .authorizedAlways, .authorizedWhenInUse:
manager.requestLocation()
case .denied, .restricted:
onFailure?("定位权限未开启,请在系统设置中允许访问位置")
@unknown default:
onFailure?("定位状态不可用")
}
}
///
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
switch manager.authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse:
manager.requestLocation()
case .denied, .restricted:
onFailure?("定位权限未开启,请在系统设置中允许访问位置")
default:
break
}
}
///
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let location = locations.last {
onLocation?(location)
}
}
///
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
onFailure?("定位失败:\(error.localizedDescription)")
}
}

View File

@ -0,0 +1,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
}
}