Add scenic selection and cooperation order flows aligned with Android.

Upgrade scenic picker with search, location sorting, permission apply/status, and blocking home dialog; add cooperation order module and order source picker improvements with unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 10:48:43 +08:00
parent 3c4ca7eefb
commit 2bea05b1d9
50 changed files with 5603 additions and 191 deletions

View File

@ -63,4 +63,29 @@ final class HomeAPI {
)
)
}
///
func roleApplyPending() async throws -> [RoleApplyPendingResponse] {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/role-apply/all")
)
}
///
func scenicListAll() async throws -> ScenicListAllResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/scenic/list-all")
)
}
///
func submitRoleApply(scenicIds: [Int], roleId: Int) async throws -> RoleApplySubmitResult {
try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/role-apply/submit",
body: RoleApplySubmitRequest(scenicId: scenicIds, roleId: roleId)
)
)
}
}

View File

@ -0,0 +1,107 @@
//
// PermissionApplyModels.swift
// suixinkan
//
import Foundation
struct RoleApplyPendingResponse: Decodable, Equatable {
let code: String
let roleId: Int
let roleName: String
let scenicList: [RoleApplyScenicItem]
let status: Int
let statusLabel: String
let createdAt: String
let auditNote: String
let auditedAt: String?
let auditedBy: String?
enum CodingKeys: String, CodingKey {
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 auditNote = "audit_note"
case auditedAt = "audited_at"
case auditedBy = "audited_by"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decodeIfPresent(String.self, forKey: .code) ?? ""
roleId = try container.decodeIfPresent(Int.self, forKey: .roleId) ?? 0
roleName = try container.decodeIfPresent(String.self, forKey: .roleName) ?? ""
scenicList = try container.decodeIfPresent([RoleApplyScenicItem].self, forKey: .scenicList) ?? []
status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0
statusLabel = try container.decodeIfPresent(String.self, forKey: .statusLabel) ?? ""
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
auditNote = try container.decodeIfPresent(String.self, forKey: .auditNote) ?? ""
auditedAt = try container.decodeIfPresent(String.self, forKey: .auditedAt)
auditedBy = try container.decodeIfPresent(String.self, forKey: .auditedBy)
}
}
struct RoleApplyScenicItem: Decodable, Equatable {
let id: Int
let name: String
}
struct ScenicListAllResponse: Decodable, Equatable {
let total: Int
let list: [ScenicListItem]
init(total: Int = 0, list: [ScenicListItem] = []) {
self.total = total
self.list = list
}
}
struct ScenicListItem: Decodable, Equatable {
let id: Int
let name: String
}
struct RoleApplySubmitRequest: Encodable {
let scenicId: [Int]
let roleId: Int
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case roleId = "role_id"
}
}
struct RoleOption: Equatable, Hashable {
let id: Int
let name: String
let description: String
var isSelected: Bool
}
struct ScenicOption: Equatable, Hashable {
let id: Int
let name: String
var isSelected: Bool
var isDisabled: Bool
}
struct RoleApplySubmitResult: Decodable, Equatable {
let code: String
init(code: String = "") {
self.code = code
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decodeIfPresent(String.self, forKey: .code) ?? ""
}
private enum CodingKeys: String, CodingKey {
case code
}
}

View File

@ -0,0 +1,149 @@
//
// ScenicSelectionModels.swift
// suixinkan
//
import Foundation
/// Android `openStatus`
enum ScenicSelectionOpenStatus: Equatable {
case nearest
case closed
case none
}
/// Android `Sceniclistv2Entity`
struct ScenicSpotDisplayItem: Equatable, Hashable {
let id: Int
let name: String
let coverImg: String
let address: String
let distanceText: String
let openStatus: ScenicSelectionOpenStatus
let isSelected: Bool
var statusLabel: String? {
switch openStatus {
case .nearest: "距离最近"
case .closed: "暂未营业"
case .none: nil
}
}
}
/// Android Haversine
enum ScenicDistanceCalculator {
private static let earthRadiusMeters = 6_371_000.0
static func distanceKilometers(
fromLat: Double,
fromLng: Double,
toLat: Double,
toLng: Double
) -> Double {
let dLat = (toLat - fromLat) * .pi / 180
let dLng = (toLng - fromLng) * .pi / 180
let lat1 = fromLat * .pi / 180
let lat2 = toLat * .pi / 180
let a = sin(dLat / 2) * sin(dLat / 2)
+ cos(lat1) * cos(lat2) * sin(dLng / 2) * sin(dLng / 2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
return (earthRadiusMeters * c) / 1000
}
static func formatDistanceKilometers(_ kilometers: Double) -> String {
if kilometers <= 0 || !kilometers.isFinite {
return "0"
}
return String(format: "%.2f", kilometers)
}
}
/// role-permission
enum ScenicSpotDisplayMapper {
static func buildItems(
scenicList: [ScenicInfo],
searchQuery: String,
currentLat: Double?,
currentLng: Double?,
selectedScenicId: Int
) -> [ScenicSpotDisplayItem] {
let hasLocation = (currentLat ?? 0) > 0 && (currentLng ?? 0) > 0
let searchLower = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
struct ScenicWithDistance {
let item: ScenicSpotDisplayItem
let distance: Double
}
let mapped = scenicList.map { scenic -> ScenicWithDistance in
let loc = scenic.location
let distance: Double
if let loc, hasLocation, let currentLat, let currentLng {
distance = ScenicDistanceCalculator.distanceKilometers(
fromLat: currentLat,
fromLng: currentLng,
toLat: loc.lat,
toLng: loc.lng
)
} else if loc == nil, hasLocation {
distance = .greatestFiniteMagnitude
} else {
distance = 0
}
let address = loc?.address.isEmpty == false ? (loc?.address ?? "--") : "--"
let distanceText: String
if loc == nil {
distanceText = "--"
} else if distance > 0, distance < .greatestFiniteMagnitude {
distanceText = ScenicDistanceCalculator.formatDistanceKilometers(distance)
} else {
distanceText = "0"
}
let openStatus: ScenicSelectionOpenStatus = scenic.status == 2 ? .closed : .none
let item = ScenicSpotDisplayItem(
id: scenic.id,
name: scenic.name,
coverImg: scenic.coverImg ?? "",
address: address,
distanceText: distanceText,
openStatus: openStatus,
isSelected: selectedScenicId > 0 && scenic.id == selectedScenicId
)
return ScenicWithDistance(item: item, distance: distance)
}
let filtered = mapped.filter { entry in
guard !searchLower.isEmpty else { return true }
return entry.item.name.lowercased().contains(searchLower)
|| entry.item.address.lowercased().contains(searchLower)
}
let sorted: [ScenicWithDistance]
if hasLocation {
sorted = filtered.sorted { $0.distance < $1.distance }
} else {
sorted = filtered
}
let baseItems = sorted.map(\.item)
guard hasLocation, !baseItems.isEmpty else { return baseItems }
return baseItems.enumerated().map { index, item in
if index == 0, item.openStatus != .closed {
return ScenicSpotDisplayItem(
id: item.id,
name: item.name,
coverImg: item.coverImg,
address: item.address,
distanceText: item.distanceText,
openStatus: .nearest,
isSelected: item.isSelected
)
}
return item
}
}
}

View File

@ -47,7 +47,7 @@ enum HomeRouteHandler {
showToast("暂无合作订单权限", from: viewController)
return
}
showDeveloping(from: viewController)
viewController.navigationController?.pushViewController(CooperationOrderListViewController(), animated: true)
case "task_management", "task_management_editor":
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
default:

View File

@ -212,9 +212,17 @@ final class HomeViewModel {
}
}
///
/// Android `initCurrentScenicName`
func refreshLocalDisplayState() {
currentScenicName = appStore.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
let scenicId = appStore.currentScenicId
let scenicName = appStore.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
if scenicId > 0, !scenicName.isEmpty {
currentScenicName = scenicName
} else {
currentScenicName = ""
appStore.currentScenicId = 0
appStore.currentScenicName = ""
}
currentAppRole = appStore.currentAppRole
isMinimalTopRole = currentAppRole?.usesHomeMinimalTopLayout ?? false
}

View File

@ -0,0 +1,48 @@
//
// PermissionApplyStatusViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android `PermissionApplyStatusViewModel`
final class PermissionApplyStatusViewModel {
private(set) var pendingData: RoleApplyPendingResponse?
private(set) var isLoading = false
private(set) var rolePermissionList: [RolePermissionResponse] = []
var onStateChange: (() -> Void)?
var onNavigateToEdit: ((RoleApplyPendingResponse) -> Void)?
private let applyCode: String
private let appStore: AppStore
init(applyCode: String, appStore: AppStore = .shared) {
self.applyCode = applyCode
self.appStore = appStore
rolePermissionList = appStore.rolePermissionList()
}
func loadPendingData(api: HomeAPI) async {
isLoading = true
notifyStateChange()
do {
let pendingList = try await api.roleApplyPending()
pendingData = pendingList.first { $0.code == applyCode }
} catch {
pendingData = nil
}
isLoading = false
notifyStateChange()
}
func navigateToEditIfRejected() {
guard let pendingData, pendingData.status == PermissionApplyPendingStatus.rejected else { return }
onNavigateToEdit?(pendingData)
}
private func notifyStateChange() {
onStateChange?()
}
}

View File

@ -0,0 +1,199 @@
//
// PermissionApplyViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android `PermissionApplyViewModel`
final class PermissionApplyViewModel {
private(set) var availableRoles: [RoleOption] = []
private(set) var selectedRoleId: Int?
private(set) var selectedRoleName = ""
private(set) var availableScenics: [ScenicOption] = []
private(set) var selectedScenicIds: [Int] = []
private(set) var selectedScenicNames: [String] = []
private(set) var isLoadingScenics = false
private(set) var isSubmitting = false
private(set) var showRolePicker = false
private(set) var showScenicPicker = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onSubmitSuccess: ((String) -> Void)?
private let appStore: AppStore
init(appStore: AppStore = .shared) {
self.appStore = appStore
loadRolesFromLocal()
}
/// status=3
func initWithPendingData(_ pending: RoleApplyPendingResponse) {
selectedRoleId = pending.roleId
selectedRoleName = pending.roleName
selectedScenicIds = pending.scenicList.map(\.id)
selectedScenicNames = pending.scenicList.map(\.name)
availableRoles = availableRoles.map { role in
var copy = role
copy.isSelected = role.id == pending.roleId
return copy
}
notifyStateChange()
}
func loadRolesFromLocal() {
let rolePermissionList = appStore.rolePermissionList()
var seen = Set<Int>()
availableRoles = rolePermissionList.compactMap { item in
guard !seen.contains(item.role.id) else { return nil }
seen.insert(item.role.id)
return RoleOption(
id: item.role.id,
name: item.role.name,
description: item.role.notes ?? "",
isSelected: false
)
}
notifyStateChange()
}
func showRolePickerSheet() {
showRolePicker = true
notifyStateChange()
}
func hideRolePickerSheet() {
showRolePicker = false
notifyStateChange()
}
func selectRole(_ role: RoleOption) {
selectedRoleId = role.id
selectedRoleName = role.name
availableRoles = availableRoles.map { item in
var copy = item
copy.isSelected = item.id == role.id
return copy
}
selectedScenicIds = []
selectedScenicNames = []
availableScenics = []
showRolePicker = false
notifyStateChange()
}
func showScenicPickerSheet() {
guard selectedRoleId != nil else {
onShowMessage?("请先选择角色")
return
}
showScenicPicker = true
notifyStateChange()
}
func hideScenicPickerSheet() {
showScenicPicker = false
notifyStateChange()
}
func loadAvailableScenics(api: HomeAPI?) async {
guard let roleId = selectedRoleId else { return }
guard let api else { return }
isLoadingScenics = true
notifyStateChange()
do {
let response = try await api.scenicListAll()
let existingIds = existingScenicIds(for: roleId)
availableScenics = response.list.map { scenic in
let isExisting = existingIds.contains(scenic.id)
let isPreselected = selectedScenicIds.contains(scenic.id)
return ScenicOption(
id: scenic.id,
name: scenic.name,
isSelected: isExisting || isPreselected,
isDisabled: isExisting
)
}
} catch {
onShowMessage?("获取景区列表失败")
}
isLoadingScenics = false
notifyStateChange()
}
func toggleScenic(_ scenic: ScenicOption) {
guard !scenic.isDisabled else { return }
if selectedScenicIds.contains(scenic.id) {
selectedScenicIds.removeAll { $0 == scenic.id }
selectedScenicNames.removeAll { $0 == scenic.name }
} else {
selectedScenicIds.append(scenic.id)
selectedScenicNames.append(scenic.name)
}
availableScenics = availableScenics.map { item in
var copy = item
if !copy.isDisabled {
copy.isSelected = selectedScenicIds.contains(copy.id)
}
return copy
}
notifyStateChange()
}
func removeSelectedScenic(id: Int, name: String) {
selectedScenicIds.removeAll { $0 == id }
selectedScenicNames.removeAll { $0 == name }
availableScenics = availableScenics.map { item in
var copy = item
if !copy.isDisabled {
copy.isSelected = selectedScenicIds.contains(copy.id)
}
return copy
}
notifyStateChange()
}
///
func existingScenics(for roleId: Int) -> [ScenicInfo] {
appStore.rolePermissionList()
.filter { $0.role.id == roleId }
.flatMap(\.scenic)
}
func submit(api: HomeAPI) async {
guard let roleId = selectedRoleId else {
onShowMessage?("请先选择角色")
return
}
let newScenicIds = selectedScenicIds.filter { id in
!existingScenicIds(for: roleId).contains(id)
}
guard !newScenicIds.isEmpty else {
onShowMessage?("请至少选择一个景区")
return
}
isSubmitting = true
notifyStateChange()
do {
let result = try await api.submitRoleApply(scenicIds: newScenicIds, roleId: roleId)
onShowMessage?("提交成功")
onSubmitSuccess?(result.code)
} catch {
onShowMessage?("提交失败,请稍后重试")
}
isSubmitting = false
notifyStateChange()
}
private func existingScenicIds(for roleId: Int) -> Set<Int> {
Set(existingScenics(for: roleId).map(\.id))
}
private func notifyStateChange() {
onStateChange?()
}
}

View File

@ -0,0 +1,121 @@
//
// ScenicSelectionViewModel.swift
// suixinkan
//
import Foundation
/// Android role-apply pending status
enum PermissionApplyPendingStatus {
static let reviewing = 1
static let rejected = 3
}
/// ViewModel Android `ScenicSelectionViewModel`
final class ScenicSelectionViewModel {
private(set) var spots: [ScenicSpotDisplayItem] = []
private(set) var searchQuery = ""
private(set) var currentLocationText = "最近的景区"
private(set) var isLoading = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onSelectionComplete: (() -> Void)?
var onNavigateApply: (() -> Void)?
var onNavigateApplyStatus: ((String) -> Void)?
private let appStore: AppStore
private let locationProvider: HomeLocationProvider
private var currentLat: Double?
private var currentLng: Double?
init(appStore: AppStore = .shared, locationProvider: HomeLocationProvider = HomeLocationProvider()) {
self.appStore = appStore
self.locationProvider = locationProvider
}
func loadFromLocal() {
let scenicList = resolvedScenicList()
spots = ScenicSpotDisplayMapper.buildItems(
scenicList: scenicList,
searchQuery: searchQuery,
currentLat: currentLat,
currentLng: currentLng,
selectedScenicId: appStore.currentScenicId
)
notifyStateChange()
}
func onSearchQueryChange(_ query: String) {
searchQuery = query
loadFromLocal()
}
func startLocation() async {
isLoading = true
notifyStateChange()
do {
let snapshot = try await locationProvider.requestSnapshot()
currentLat = snapshot.latitude
currentLng = snapshot.longitude
let address = snapshot.address.trimmingCharacters(in: .whitespacesAndNewlines)
currentLocationText = address.isEmpty ? "最近的景区" : address
loadFromLocal()
} catch HomeLocationProviderError.permissionDenied {
onShowMessage?("未授予定位权限,无法获取附近景区")
} catch {
onShowMessage?("定位失败: \(error.localizedDescription)")
}
isLoading = false
notifyStateChange()
}
func selectSpot(_ spot: ScenicSpotDisplayItem) {
appStore.currentScenicId = spot.id
appStore.currentScenicName = spot.name
NotificationCenter.default.post(
name: NotificationName.scenicDidChange,
object: nil,
userInfo: [
NotificationUserInfoKey.scenicId: spot.id,
NotificationUserInfoKey.scenicName: spot.name,
]
)
loadFromLocal()
onSelectionComplete?()
}
func onApplyNow(api: HomeAPI) async {
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
let pendingList = try await api.roleApplyPending()
if let first = pendingList.first,
first.status == PermissionApplyPendingStatus.reviewing
|| first.status == PermissionApplyPendingStatus.rejected {
onNavigateApplyStatus?(first.code)
} else {
onNavigateApply?()
}
} catch {
onNavigateApply?()
}
}
private func resolvedScenicList() -> [ScenicInfo] {
let cached = appStore.roleScenicList()
if !cached.isEmpty { return cached }
let rolePermissionList = appStore.rolePermissionList()
guard !rolePermissionList.isEmpty else { return [] }
return appStore.matchRolePermissionItem(in: rolePermissionList)?.scenic ?? []
}
private func notifyStateChange() {
onStateChange?()
}
}