Implement permission-driven home tab aligned with Android.
Load role-permission on first visit to drive menus, role-based layout, store card, and location reporting with account-scoped persistence and unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -15,6 +15,7 @@ final class NetworkServices {
|
||||
let profileAPI: ProfileAPI
|
||||
let statisticsAPI: StatisticsAPI
|
||||
let orderAPI: OrderAPI
|
||||
let homeAPI: HomeAPI
|
||||
let uploadAPI: UploadAPI
|
||||
let ossUploadService: OSSUploadService
|
||||
|
||||
@ -25,6 +26,7 @@ final class NetworkServices {
|
||||
profileAPI = ProfileAPI(client: client)
|
||||
statisticsAPI = StatisticsAPI(client: client)
|
||||
orderAPI = OrderAPI(client: client)
|
||||
homeAPI = HomeAPI(client: client)
|
||||
uploadAPI = UploadAPI(client: client)
|
||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||
client.bindAuthTokenProvider {
|
||||
@ -40,6 +42,7 @@ final class NetworkServices {
|
||||
profileAPI = ProfileAPI(client: apiClient)
|
||||
statisticsAPI = StatisticsAPI(client: apiClient)
|
||||
orderAPI = OrderAPI(client: apiClient)
|
||||
homeAPI = HomeAPI(client: apiClient)
|
||||
uploadAPI = UploadAPI(client: apiClient)
|
||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||
}
|
||||
|
||||
@ -26,6 +26,13 @@ final class AppStore {
|
||||
static let currentScenicId = "key_in_current_scenic_id"
|
||||
static let currentScenicName = "key_in_current_scenic_name"
|
||||
static let currentStoreId = "key_in_current_store_id"
|
||||
static let legacyRoleId = "key_in_role_id"
|
||||
static let rolePermissionList = "key_role_permission_list"
|
||||
static let permissionItems = "key_in_permission"
|
||||
static let roleScenicList = "key_current_role_scenic_list"
|
||||
static let onlineStatus = "key_online_status"
|
||||
static let lastLocationReportTime = "key_last_location_report_time"
|
||||
static let locationReminderMinutes = "key_location_reminder_minutes"
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
@ -123,6 +130,39 @@ final class AppStore {
|
||||
set { defaults.set(newValue, forKey: Key.currentStoreId) }
|
||||
}
|
||||
|
||||
/// 旧版 role_id,用于权限匹配兜底。
|
||||
var legacyRoleId: Int {
|
||||
get { defaults.integer(forKey: accountScopedKey(Key.legacyRoleId)) }
|
||||
set { defaults.set(newValue, forKey: accountScopedKey(Key.legacyRoleId)) }
|
||||
}
|
||||
|
||||
/// 是否在线(位置上报会话)。
|
||||
var onlineStatus: Bool {
|
||||
get { defaults.bool(forKey: accountScopedKey(Key.onlineStatus)) }
|
||||
set { defaults.set(newValue, forKey: accountScopedKey(Key.onlineStatus)) }
|
||||
}
|
||||
|
||||
/// 上次位置上报时间戳(毫秒)。
|
||||
var lastLocationReportTime: Int64 {
|
||||
get { Int64(defaults.integer(forKey: accountScopedKey(Key.lastLocationReportTime))) }
|
||||
set { defaults.set(Int(newValue), forKey: accountScopedKey(Key.lastLocationReportTime)) }
|
||||
}
|
||||
|
||||
/// 位置超时提前提醒分钟数,0 表示不提醒。
|
||||
var locationReminderMinutes: Int {
|
||||
get { defaults.integer(forKey: accountScopedKey(Key.locationReminderMinutes)) }
|
||||
set { defaults.set(newValue, forKey: accountScopedKey(Key.locationReminderMinutes)) }
|
||||
}
|
||||
|
||||
/// 当前账号缓存前缀,对齐 Android `getAccountCachePrefix`。
|
||||
var accountCachePrefix: String {
|
||||
let uid = userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let type = accountType.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if uid.isEmpty { return "guest" }
|
||||
if type.isEmpty { return uid }
|
||||
return "\(uid)_\(type)"
|
||||
}
|
||||
|
||||
/// 当前解析后的业务角色。
|
||||
var currentAppRole: AppRoleCode? {
|
||||
AppRoleCode.fromCode(roleCode) ?? AppRoleCode.fromRoleName(roleName)
|
||||
@ -183,6 +223,7 @@ final class AppStore {
|
||||
|
||||
/// 清除登录态与会话快照。
|
||||
func logout() {
|
||||
clearPermissionSnapshot()
|
||||
token = ""
|
||||
userId = ""
|
||||
userName = ""
|
||||
@ -197,4 +238,82 @@ final class AppStore {
|
||||
currentScenicName = ""
|
||||
currentStoreId = 0
|
||||
}
|
||||
|
||||
/// 保存完整 role-permission 列表。
|
||||
func saveRolePermissionList(_ list: [RolePermissionResponse]) {
|
||||
guard let data = try? JSONEncoder().encode(list) else { return }
|
||||
defaults.set(data, forKey: accountScopedKey(Key.rolePermissionList))
|
||||
}
|
||||
|
||||
/// 读取完整 role-permission 列表。
|
||||
func rolePermissionList() -> [RolePermissionResponse] {
|
||||
guard let data = defaults.data(forKey: accountScopedKey(Key.rolePermissionList)),
|
||||
let list = try? JSONDecoder().decode([RolePermissionResponse].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
/// 保存当前角色扁平权限。
|
||||
func savePermissionItems(_ items: [HomePermissionItem]) {
|
||||
guard let data = try? JSONEncoder().encode(items) else { return }
|
||||
defaults.set(data, forKey: accountScopedKey(Key.permissionItems))
|
||||
}
|
||||
|
||||
/// 读取当前角色扁平权限。
|
||||
func permissionItems() -> [HomePermissionItem] {
|
||||
guard let data = defaults.data(forKey: accountScopedKey(Key.permissionItems)),
|
||||
let items = try? JSONDecoder().decode([HomePermissionItem].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/// 保存当前角色可选景区列表。
|
||||
func saveRoleScenicList(_ scenicList: [ScenicInfo]) {
|
||||
guard let data = try? JSONEncoder().encode(scenicList) else { return }
|
||||
defaults.set(data, forKey: accountScopedKey(Key.roleScenicList))
|
||||
}
|
||||
|
||||
/// 读取当前角色可选景区列表。
|
||||
func roleScenicList() -> [ScenicInfo] {
|
||||
guard let data = defaults.data(forKey: accountScopedKey(Key.roleScenicList)),
|
||||
let list = try? JSONDecoder().decode([ScenicInfo].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
/// 在 role-permission 列表中匹配当前账号角色。
|
||||
func matchRolePermissionItem(in list: [RolePermissionResponse]) -> RolePermissionResponse? {
|
||||
RolePermissionMatcher.match(
|
||||
in: list,
|
||||
roleCode: roleCode,
|
||||
roleName: roleName,
|
||||
legacyRoleId: legacyRoleId
|
||||
)
|
||||
}
|
||||
|
||||
/// 清除上次位置上报时间。
|
||||
func clearLastLocationReportTime() {
|
||||
defaults.removeObject(forKey: accountScopedKey(Key.lastLocationReportTime))
|
||||
}
|
||||
|
||||
/// 清除权限与位置会话快照。
|
||||
func clearPermissionSnapshot() {
|
||||
let prefix = accountScopedKey("")
|
||||
let keys = [
|
||||
Key.rolePermissionList,
|
||||
Key.permissionItems,
|
||||
Key.roleScenicList,
|
||||
Key.onlineStatus,
|
||||
Key.lastLocationReportTime,
|
||||
Key.locationReminderMinutes,
|
||||
]
|
||||
keys.forEach { defaults.removeObject(forKey: prefix + $0) }
|
||||
}
|
||||
|
||||
private func accountScopedKey(_ key: String) -> String {
|
||||
"\(accountCachePrefix)_\(key)"
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,6 +45,20 @@ enum AppRoleCode: String, CaseIterable, Equatable {
|
||||
self == .photographer
|
||||
}
|
||||
|
||||
/// 首页隐藏顶部在线/位置/快捷操作的角色。
|
||||
static let homeMinimalTopRoles: Set<AppRoleCode> = [
|
||||
.storeAdmin,
|
||||
.editor,
|
||||
.scenicCS,
|
||||
.scenicAdmin,
|
||||
.scenicOperation,
|
||||
]
|
||||
|
||||
/// 当前角色是否使用首页精简顶部布局。
|
||||
var usesHomeMinimalTopLayout: Bool {
|
||||
AppRoleCode.homeMinimalTopRoles.contains(self)
|
||||
}
|
||||
|
||||
/// 从后端 role_code 解析角色。
|
||||
static func fromCode(_ code: String?) -> AppRoleCode? {
|
||||
let normalized = code?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
66
suixinkan/Features/Home/API/HomeAPI.swift
Normal file
66
suixinkan/Features/Home/API/HomeAPI.swift
Normal file
@ -0,0 +1,66 @@
|
||||
//
|
||||
// HomeAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 首页 API,封装权限、门店与位置上报相关接口。
|
||||
final class HomeAPI {
|
||||
private let client: APIClient
|
||||
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 拉取当前账号全部角色权限配置。
|
||||
func rolePermissions() async throws -> [RolePermissionResponse] {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/role-permission")
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取全部门店列表。
|
||||
func storeList() async throws -> StoreListResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/store/all")
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取摄影师位置上报详情。
|
||||
func locationDetail(staffId: String) async throws -> LocationDetailResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/loacation/detail",
|
||||
queryItems: [URLQueryItem(name: "staff_id", value: staffId)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 上报摄影师位置。
|
||||
func reportLocation(
|
||||
staffId: String,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
address: String,
|
||||
type: Int,
|
||||
scenicId: String
|
||||
) async throws -> LocationReportResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/loacation/report",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "staff_id", value: staffId),
|
||||
URLQueryItem(name: "latitude", value: String(latitude)),
|
||||
URLQueryItem(name: "longitude", value: String(longitude)),
|
||||
URLQueryItem(name: "address", value: address),
|
||||
URLQueryItem(name: "type", value: String(type)),
|
||||
URLQueryItem(name: "scenic_id", value: scenicId),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
28
suixinkan/Features/Home/Models/HomeMenuItem.swift
Normal file
28
suixinkan/Features/Home/Models/HomeMenuItem.swift
Normal file
@ -0,0 +1,28 @@
|
||||
//
|
||||
// HomeMenuItem.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 首页菜单展示项,由服务端权限 URI 与本地 catalog 合并而成。
|
||||
struct HomeMenuItem: Equatable, Hashable, Sendable {
|
||||
let uri: String
|
||||
let title: String
|
||||
let iconName: String
|
||||
let serverName: String
|
||||
|
||||
init(uri: String, title: String, iconName: String, serverName: String = "") {
|
||||
self.uri = uri
|
||||
self.title = title
|
||||
self.iconName = iconName
|
||||
self.serverName = serverName
|
||||
}
|
||||
|
||||
/// 固定的「更多功能」入口。
|
||||
static let moreFunctions = HomeMenuItem(
|
||||
uri: "more_functions",
|
||||
title: "更多功能",
|
||||
iconName: "square.grid.2x2"
|
||||
)
|
||||
}
|
||||
38
suixinkan/Features/Home/Models/LocationModels.swift
Normal file
38
suixinkan/Features/Home/Models/LocationModels.swift
Normal file
@ -0,0 +1,38 @@
|
||||
//
|
||||
// LocationModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 位置上报详情,对齐 Android `LocationDetailResponse`。
|
||||
struct LocationDetailResponse: Decodable, Sendable {
|
||||
let status: Int
|
||||
let needReport: Bool
|
||||
let expireTime: Int64
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status
|
||||
case needReport = "need_report"
|
||||
case expireTime = "expire_time"
|
||||
}
|
||||
|
||||
init(status: Int = 0, needReport: Bool = true, expireTime: Int64 = 0) {
|
||||
self.status = status
|
||||
self.needReport = needReport
|
||||
self.expireTime = expireTime
|
||||
}
|
||||
}
|
||||
|
||||
/// 位置上报结果,对齐 Android `LocationReportResponse`。
|
||||
struct LocationReportResponse: Decodable, Sendable {
|
||||
let staffId: String
|
||||
let expired: Int64
|
||||
let status: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case staffId = "staff_id"
|
||||
case expired
|
||||
case status
|
||||
}
|
||||
}
|
||||
143
suixinkan/Features/Home/Models/RolePermissionModels.swift
Normal file
143
suixinkan/Features/Home/Models/RolePermissionModels.swift
Normal file
@ -0,0 +1,143 @@
|
||||
//
|
||||
// RolePermissionModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 角色权限接口根节点,对齐 Android `RolePermissionResponse`。
|
||||
struct RolePermissionResponse: Codable, Equatable, Sendable {
|
||||
let role: RoleInfo
|
||||
let scenic: [ScenicInfo]
|
||||
|
||||
init(role: RoleInfo, scenic: [ScenicInfo] = []) {
|
||||
self.role = role
|
||||
self.scenic = scenic
|
||||
}
|
||||
}
|
||||
|
||||
/// 角色信息与顶层权限列表。
|
||||
struct RoleInfo: Codable, Equatable, Sendable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let roleCode: String
|
||||
let notes: String?
|
||||
let permission: [PermissionItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case roleCode = "role_code"
|
||||
case notes
|
||||
case permission
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
name: String = "",
|
||||
roleCode: String = "",
|
||||
notes: String? = nil,
|
||||
permission: [PermissionItem] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.roleCode = roleCode
|
||||
self.notes = notes
|
||||
self.permission = permission
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限树节点。
|
||||
struct PermissionItem: Codable, Equatable, Sendable {
|
||||
let id: Int
|
||||
let pid: Int
|
||||
let name: String
|
||||
let uri: String
|
||||
let iconSrc: String?
|
||||
let children: [PermissionItem]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case pid
|
||||
case name
|
||||
case uri
|
||||
case iconSrc = "icon_src"
|
||||
case children
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
pid: Int = 0,
|
||||
name: String = "",
|
||||
uri: String = "",
|
||||
iconSrc: String? = nil,
|
||||
children: [PermissionItem]? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.pid = pid
|
||||
self.name = name
|
||||
self.uri = uri
|
||||
self.iconSrc = iconSrc
|
||||
self.children = children
|
||||
}
|
||||
}
|
||||
|
||||
/// 角色绑定的景区信息。
|
||||
struct ScenicInfo: Codable, Equatable, Sendable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let status: Int
|
||||
let location: ScenicLocationInfo?
|
||||
let coverImg: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case status
|
||||
case location
|
||||
case coverImg = "cover_img"
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
name: String = "",
|
||||
status: Int = 0,
|
||||
location: ScenicLocationInfo? = nil,
|
||||
coverImg: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.status = status
|
||||
self.location = location
|
||||
self.coverImg = coverImg
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区位置信息。
|
||||
struct ScenicLocationInfo: Codable, Equatable, Sendable, Hashable {
|
||||
let lng: Double
|
||||
let lat: Double
|
||||
let address: String
|
||||
}
|
||||
|
||||
/// 扁平化后的权限项,供首页菜单与本地缓存使用。
|
||||
struct HomePermissionItem: Codable, Equatable, Sendable, Hashable {
|
||||
let id: String
|
||||
let name: String
|
||||
let uri: String
|
||||
let pid: Int
|
||||
|
||||
init(id: String, name: String, uri: String, pid: Int = 0) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.uri = uri
|
||||
self.pid = pid
|
||||
}
|
||||
|
||||
init(from item: PermissionItem) {
|
||||
self.id = String(item.id)
|
||||
self.name = item.name
|
||||
self.uri = item.uri
|
||||
self.pid = item.pid
|
||||
}
|
||||
}
|
||||
52
suixinkan/Features/Home/Models/StoreModels.swift
Normal file
52
suixinkan/Features/Home/Models/StoreModels.swift
Normal file
@ -0,0 +1,52 @@
|
||||
//
|
||||
// StoreModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 门店列表项,对齐 Android `StoreItem`。
|
||||
struct StoreItem: Codable, Equatable, Sendable {
|
||||
let id: Int
|
||||
let scenicId: Int
|
||||
let name: String
|
||||
let address: String
|
||||
let status: Int
|
||||
let statusText: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case scenicId = "scenic_id"
|
||||
case name
|
||||
case address
|
||||
case status
|
||||
case statusText = "status_text"
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
scenicId: Int = 0,
|
||||
name: String = "",
|
||||
address: String = "",
|
||||
status: Int = 0,
|
||||
statusText: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.scenicId = scenicId
|
||||
self.name = name
|
||||
self.address = address
|
||||
self.status = status
|
||||
self.statusText = statusText
|
||||
}
|
||||
}
|
||||
|
||||
/// 门店列表响应,对齐 Android `ListResponse<StoreItem>`。
|
||||
struct StoreListResponse: Codable, Sendable {
|
||||
let total: Int
|
||||
let list: [StoreItem]
|
||||
|
||||
init(total: Int = 0, list: [StoreItem] = []) {
|
||||
self.total = total
|
||||
self.list = list
|
||||
}
|
||||
}
|
||||
50
suixinkan/Features/Home/Services/HomeCommonMenuStore.swift
Normal file
50
suixinkan/Features/Home/Services/HomeCommonMenuStore.swift
Normal file
@ -0,0 +1,50 @@
|
||||
//
|
||||
// HomeCommonMenuStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 常用应用 URI 持久化,按账号与角色作用域存储。
|
||||
final class HomeCommonMenuStore {
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
/// 读取已保存的常用 URI;无记录时返回空数组。
|
||||
func savedCommonURIs(accountScope: String, roleCode: String) -> [String] {
|
||||
defaults.stringArray(forKey: storageKey(accountScope: accountScope, roleCode: roleCode)) ?? []
|
||||
}
|
||||
|
||||
/// 保存常用 URI 列表。
|
||||
func saveCommonURIs(_ uris: [String], accountScope: String, roleCode: String) {
|
||||
defaults.set(uris, forKey: storageKey(accountScope: accountScope, roleCode: roleCode))
|
||||
}
|
||||
|
||||
/// 根据权限生成默认常用 URI(最多 4 个)。
|
||||
static func defaultCommonURIs(from permissions: [HomePermissionItem]) -> [String] {
|
||||
let uris = permissions.map(\.uri)
|
||||
return uris.count > 4 ? Array(uris.prefix(4)) : uris
|
||||
}
|
||||
|
||||
/// 构建常用菜单列表。
|
||||
func commonMenus(
|
||||
from allMenus: [HomeMenuItem],
|
||||
permissions: [HomePermissionItem],
|
||||
accountScope: String,
|
||||
roleCode: String
|
||||
) -> [HomeMenuItem] {
|
||||
let saved = savedCommonURIs(accountScope: accountScope, roleCode: roleCode)
|
||||
let defaultURIs = Self.defaultCommonURIs(from: permissions)
|
||||
let commonURIs = saved.isEmpty ? defaultURIs : saved
|
||||
let commonSet = Set(commonURIs)
|
||||
return allMenus.filter { commonSet.contains($0.uri) }
|
||||
}
|
||||
|
||||
private func storageKey(accountScope: String, roleCode: String) -> String {
|
||||
"\(accountScope)_role_\(roleCode)_common_uris"
|
||||
}
|
||||
}
|
||||
97
suixinkan/Features/Home/Services/HomeLocationProvider.swift
Normal file
97
suixinkan/Features/Home/Services/HomeLocationProvider.swift
Normal file
@ -0,0 +1,97 @@
|
||||
//
|
||||
// HomeLocationProvider.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
|
||||
/// 首页位置采集结果。
|
||||
struct HomeLocationSnapshot: Sendable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let address: String
|
||||
}
|
||||
|
||||
/// 首页位置采集,基于 CoreLocation 并提供可选逆地理。
|
||||
final class HomeLocationProvider: NSObject, CLLocationManagerDelegate {
|
||||
|
||||
private let manager = CLLocationManager()
|
||||
private var coordinateContinuation: CheckedContinuation<CLLocationCoordinate2D, Error>?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
}
|
||||
|
||||
/// 请求当前位置与地址。
|
||||
func requestSnapshot() async throws -> HomeLocationSnapshot {
|
||||
let status = manager.authorizationStatus
|
||||
if status == .notDetermined {
|
||||
manager.requestWhenInUseAuthorization()
|
||||
try await Task.sleep(nanoseconds: 300_000_000)
|
||||
}
|
||||
|
||||
let resolvedStatus = manager.authorizationStatus
|
||||
guard resolvedStatus == .authorizedWhenInUse || resolvedStatus == .authorizedAlways else {
|
||||
throw HomeLocationProviderError.permissionDenied
|
||||
}
|
||||
|
||||
let coordinate = try await requestCoordinate()
|
||||
let address = await reverseGeocode(latitude: coordinate.latitude, longitude: coordinate.longitude)
|
||||
return HomeLocationSnapshot(
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude,
|
||||
address: address
|
||||
)
|
||||
}
|
||||
|
||||
private func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.coordinateContinuation = continuation
|
||||
manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
let location = CLLocation(latitude: latitude, longitude: longitude)
|
||||
let geocoder = CLGeocoder()
|
||||
do {
|
||||
let placemarks = try await geocoder.reverseGeocodeLocation(location)
|
||||
return placemarks.first?.formattedAddress ?? ""
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let location = locations.last else { return }
|
||||
coordinateContinuation?.resume(returning: location.coordinate)
|
||||
coordinateContinuation = nil
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
coordinateContinuation?.resume(throwing: error)
|
||||
coordinateContinuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页定位错误。
|
||||
enum HomeLocationProviderError: LocalizedError {
|
||||
case permissionDenied
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .permissionDenied: "需要位置权限才能上线"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension CLPlacemark {
|
||||
var formattedAddress: String {
|
||||
[subLocality, locality, administrativeArea]
|
||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "")
|
||||
}
|
||||
}
|
||||
139
suixinkan/Features/Home/Services/HomeLocationStateStore.swift
Normal file
139
suixinkan/Features/Home/Services/HomeLocationStateStore.swift
Normal file
@ -0,0 +1,139 @@
|
||||
//
|
||||
// HomeLocationStateStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 首页在线状态与 2 小时倒计时存储,对齐 Android `LocationStateRepository`。
|
||||
final class HomeLocationStateStore {
|
||||
|
||||
static let onlineDurationMillis: Int64 = 2 * 60 * 60 * 1000
|
||||
|
||||
private(set) var isOnline = false
|
||||
private(set) var elapsedSeconds: Int64 = 0
|
||||
private(set) var nextReportCountdownSeconds: Int64 = 0
|
||||
private(set) var reminderMinutes = 0
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
private let store: AppStore
|
||||
private var countdownTask: Task<Void, Never>?
|
||||
private var anchorTimestamp: Int64?
|
||||
|
||||
init(store: AppStore = .shared) {
|
||||
self.store = store
|
||||
reminderMinutes = store.locationReminderMinutes
|
||||
isOnline = store.onlineStatus
|
||||
}
|
||||
|
||||
/// 启动时恢复在线倒计时。
|
||||
func restoreStateIfNeeded() {
|
||||
guard store.onlineStatus else { return }
|
||||
let lastTime = store.lastLocationReportTime
|
||||
guard lastTime > 0 else { return }
|
||||
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let elapsed = now - lastTime
|
||||
if elapsed >= Self.onlineDurationMillis {
|
||||
updateOnlineStatus(false)
|
||||
store.clearLastLocationReportTime()
|
||||
notifyChange()
|
||||
return
|
||||
}
|
||||
|
||||
anchorTimestamp = lastTime
|
||||
let remaining = Self.onlineDurationMillis - elapsed
|
||||
elapsedSeconds = max(0, elapsed / 1000)
|
||||
nextReportCountdownSeconds = max(0, remaining / 1000)
|
||||
startCountdown(anchorTime: lastTime)
|
||||
notifyChange()
|
||||
}
|
||||
|
||||
/// 更新在线状态并持久化。
|
||||
func updateOnlineStatus(_ online: Bool) {
|
||||
isOnline = online
|
||||
store.onlineStatus = online
|
||||
if !online {
|
||||
stopCountdown()
|
||||
store.clearLastLocationReportTime()
|
||||
}
|
||||
notifyChange()
|
||||
}
|
||||
|
||||
/// 更新提醒分钟数。
|
||||
func updateReminderMinutes(_ minutes: Int) {
|
||||
reminderMinutes = minutes
|
||||
store.locationReminderMinutes = minutes
|
||||
notifyChange()
|
||||
}
|
||||
|
||||
/// 开始新的位置上报会话。
|
||||
func startLocationReport(at timestamp: Int64 = Int64(Date().timeIntervalSince1970 * 1000)) {
|
||||
anchorTimestamp = timestamp
|
||||
store.lastLocationReportTime = timestamp
|
||||
isOnline = true
|
||||
store.onlineStatus = true
|
||||
elapsedSeconds = 0
|
||||
nextReportCountdownSeconds = Self.onlineDurationMillis / 1000
|
||||
startCountdown(anchorTime: timestamp)
|
||||
notifyChange()
|
||||
}
|
||||
|
||||
/// 格式化倒计时文本 `HH:MM:SS`。
|
||||
func formattedCountdown(from seconds: Int64) -> String {
|
||||
let hours = seconds / 3600
|
||||
let minutes = (seconds % 3600) / 60
|
||||
let secs = seconds % 60
|
||||
return String(format: "%02d:%02d:%02d", hours, minutes, secs)
|
||||
}
|
||||
|
||||
var countdownDisplayText: String {
|
||||
formattedCountdown(from: nextReportCountdownSeconds)
|
||||
}
|
||||
|
||||
/// 是否到达提前提醒窗口。
|
||||
func shouldTriggerTimeoutReminder() -> Bool {
|
||||
guard reminderMinutes > 0, isOnline else { return false }
|
||||
let reminderSeconds = Int64(reminderMinutes * 60)
|
||||
return nextReportCountdownSeconds > 0 && nextReportCountdownSeconds <= reminderSeconds
|
||||
}
|
||||
|
||||
private func startCountdown(anchorTime: Int64) {
|
||||
countdownTask?.cancel()
|
||||
countdownTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
guard self.isOnline else { return }
|
||||
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let elapsed = now - anchorTime
|
||||
let remaining = Self.onlineDurationMillis - elapsed
|
||||
let elapsedSeconds = max(0, elapsed / 1000)
|
||||
|
||||
self.elapsedSeconds = elapsedSeconds
|
||||
if remaining <= 0 {
|
||||
self.nextReportCountdownSeconds = 0
|
||||
self.updateOnlineStatus(false)
|
||||
self.store.clearLastLocationReportTime()
|
||||
return
|
||||
}
|
||||
self.nextReportCountdownSeconds = max(0, remaining / 1000)
|
||||
self.notifyChange()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopCountdown() {
|
||||
countdownTask?.cancel()
|
||||
countdownTask = nil
|
||||
anchorTimestamp = nil
|
||||
elapsedSeconds = 0
|
||||
nextReportCountdownSeconds = 0
|
||||
}
|
||||
|
||||
private func notifyChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
58
suixinkan/Features/Home/Services/HomeMenuCatalog.swift
Normal file
58
suixinkan/Features/Home/Services/HomeMenuCatalog.swift
Normal file
@ -0,0 +1,58 @@
|
||||
//
|
||||
// HomeMenuCatalog.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 首页菜单静态 catalog,与服务端 permission URI 取交集后展示。
|
||||
enum HomeMenuCatalog {
|
||||
|
||||
/// 全部已知菜单项,对齐 Android `Constants.menuList`。
|
||||
static let allItems: [HomeMenuItem] = [
|
||||
HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "gearshape"),
|
||||
HomeMenuItem(uri: "wallet", title: "我的钱包", iconName: "wallet.pass"),
|
||||
HomeMenuItem(uri: "cloud_management", title: "相册云盘", iconName: "icloud"),
|
||||
HomeMenuItem(uri: "asset_management", title: "素材管理", iconName: "photo.on.rectangle"),
|
||||
HomeMenuItem(uri: "task_management", title: "任务管理", iconName: "checklist"),
|
||||
HomeMenuItem(uri: "task_management_editor", title: "任务管理", iconName: "checklist"),
|
||||
HomeMenuItem(uri: "schedule_management", title: "日程管理", iconName: "calendar"),
|
||||
HomeMenuItem(uri: "system_settings", title: "设置中心", iconName: "slider.horizontal.3"),
|
||||
HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell"),
|
||||
HomeMenuItem(uri: "checkin_points", title: "打卡点管理", iconName: "mappin.and.ellipse"),
|
||||
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "photo.stack"),
|
||||
HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "video"),
|
||||
HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal"),
|
||||
HomeMenuItem(uri: "live_album", title: "直播相册", iconName: "photo.on.rectangle.angled"),
|
||||
HomeMenuItem(uri: "pm", title: "项目管理", iconName: "folder"),
|
||||
HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "folder"),
|
||||
HomeMenuItem(uri: "location_report", title: "位置上报", iconName: "location"),
|
||||
HomeMenuItem(uri: "registration_invitation", title: "注册邀请", iconName: "person.badge.plus"),
|
||||
HomeMenuItem(uri: "store", title: "店铺管理", iconName: "building.2"),
|
||||
HomeMenuItem(uri: "fly", title: "飞行管理", iconName: "airplane"),
|
||||
HomeMenuItem(uri: "pilot_cert", title: "飞手认证", iconName: "person.text.rectangle"),
|
||||
HomeMenuItem(uri: "pilot_controller", title: "飞控", iconName: "gamecontroller"),
|
||||
HomeMenuItem(uri: "/scenic-queue", title: "排队管理", iconName: "person.3"),
|
||||
HomeMenuItem(uri: "operating-area", title: "运营区域", iconName: "map"),
|
||||
HomeMenuItem(uri: "/scenic-order-manage", title: "景区订单", iconName: "doc.text"),
|
||||
HomeMenuItem(uri: "cooperation_order", title: "合作订单", iconName: "person.2"),
|
||||
]
|
||||
|
||||
/// 将服务端权限映射为可展示菜单,保持服务端顺序。
|
||||
static func visibleMenus(from permissions: [HomePermissionItem]) -> [HomeMenuItem] {
|
||||
permissions.compactMap { permission in
|
||||
guard let catalog = allItems.first(where: { $0.uri == permission.uri }) else { return nil }
|
||||
return HomeMenuItem(
|
||||
uri: catalog.uri,
|
||||
title: catalog.title,
|
||||
iconName: catalog.iconName,
|
||||
serverName: permission.name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 查找 catalog 项。
|
||||
static func item(for uri: String) -> HomeMenuItem? {
|
||||
allItems.first { $0.uri == uri }
|
||||
}
|
||||
}
|
||||
108
suixinkan/Features/Home/Services/HomeRouteHandler.swift
Normal file
108
suixinkan/Features/Home/Services/HomeRouteHandler.swift
Normal file
@ -0,0 +1,108 @@
|
||||
//
|
||||
// HomeRouteHandler.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 首页菜单路由,将 permission URI 映射到已有页面或提示。
|
||||
enum HomeRouteHandler {
|
||||
|
||||
/// 处理菜单点击。
|
||||
@MainActor
|
||||
static func open(
|
||||
uri: String,
|
||||
from viewController: UIViewController,
|
||||
storeItem: StoreItem?
|
||||
) {
|
||||
if uri == "pilot_controller" || uri == "more_functions" {
|
||||
showDeveloping(from: viewController)
|
||||
return
|
||||
}
|
||||
|
||||
if uri != "pilot_controller", AppStore.shared.currentScenicId <= 0 {
|
||||
showToast("请先选择景区", from: viewController)
|
||||
return
|
||||
}
|
||||
|
||||
switch uri {
|
||||
case "verification_order":
|
||||
selectTab(.orders, from: viewController)
|
||||
case "photographer_stats":
|
||||
selectTab(.statistics, from: viewController)
|
||||
case "system_settings", "space_settings":
|
||||
selectTab(.profile, from: viewController)
|
||||
case "pilot_cert":
|
||||
let controller = RealNameAuthViewController()
|
||||
viewController.navigationController?.pushViewController(controller, animated: true)
|
||||
case "operating-area":
|
||||
handleOperatingArea(from: viewController, storeItem: storeItem)
|
||||
case "cooperation_order":
|
||||
if !hasCooperationOrderPermission() {
|
||||
showToast("暂无合作订单权限", from: viewController)
|
||||
return
|
||||
}
|
||||
showDeveloping(from: viewController)
|
||||
default:
|
||||
showDeveloping(from: viewController)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func handleOperatingArea(from viewController: UIViewController, storeItem: StoreItem?) {
|
||||
guard let role = AppStore.shared.currentAppRole else {
|
||||
showToast("当前账号暂不支持运营区域", from: viewController)
|
||||
return
|
||||
}
|
||||
switch role {
|
||||
case .storeAdmin:
|
||||
guard let storeItem, storeItem.id > 0 else {
|
||||
showToast("请先选择店铺", from: viewController)
|
||||
return
|
||||
}
|
||||
showDeveloping(from: viewController)
|
||||
case .scenicAdmin:
|
||||
guard AppStore.shared.currentScenicId > 0 else {
|
||||
showToast("请先选择景区", from: viewController)
|
||||
return
|
||||
}
|
||||
showDeveloping(from: viewController)
|
||||
default:
|
||||
showToast("当前账号暂不支持运营区域", from: viewController)
|
||||
}
|
||||
}
|
||||
|
||||
private static func hasCooperationOrderPermission() -> Bool {
|
||||
let permissions = AppStore.shared.permissionItems()
|
||||
return permissions.contains { $0.uri == "cooperation_order" }
|
||||
|| permissions.contains(where: { containsCooperationOrder(in: $0) })
|
||||
}
|
||||
|
||||
private static func containsCooperationOrder(in item: HomePermissionItem) -> Bool {
|
||||
item.uri == "cooperation_order"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func selectTab(_ tab: AppTab, from viewController: UIViewController) {
|
||||
guard let tabBar = viewController.tabBarController as? MainTabBarController else { return }
|
||||
tabBar.selectTab(tab)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func showDeveloping(from viewController: UIViewController) {
|
||||
showToast("功能开发中", from: viewController)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func showToast(_ message: String, from viewController: UIViewController) {
|
||||
if let base = viewController as? BaseViewController {
|
||||
base.showToast(message)
|
||||
return
|
||||
}
|
||||
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
|
||||
viewController.present(alert, animated: true)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
alert.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
39
suixinkan/Features/Home/Services/RolePermissionMatcher.swift
Normal file
39
suixinkan/Features/Home/Services/RolePermissionMatcher.swift
Normal file
@ -0,0 +1,39 @@
|
||||
//
|
||||
// RolePermissionMatcher.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 角色权限匹配器,在 `role-permission` 列表中定位当前账号角色。
|
||||
enum RolePermissionMatcher {
|
||||
|
||||
/// 按 role_code → legacy id → role_name 优先级匹配权限项。
|
||||
static func match(
|
||||
in list: [RolePermissionResponse],
|
||||
roleCode: String,
|
||||
roleName: String,
|
||||
legacyRoleId: Int = 0
|
||||
) -> RolePermissionResponse? {
|
||||
if let role = AppRoleCode.fromCode(roleCode),
|
||||
let matched = list.first(where: { AppRoleCode.fromCode($0.role.roleCode) == role }) {
|
||||
return matched
|
||||
}
|
||||
|
||||
if legacyRoleId > 0,
|
||||
let matched = list.first(where: { $0.role.id == legacyRoleId }) {
|
||||
return matched
|
||||
}
|
||||
|
||||
let trimmedName = roleName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedName.isEmpty {
|
||||
return list.first { item in
|
||||
let itemName = item.role.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !itemName.isEmpty else { return false }
|
||||
return itemName.contains(trimmedName) || trimmedName.contains(itemName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
418
suixinkan/Features/Home/ViewModels/HomeViewModel.swift
Normal file
418
suixinkan/Features/Home/ViewModels/HomeViewModel.swift
Normal file
@ -0,0 +1,418 @@
|
||||
//
|
||||
// HomeViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 首页 ViewModel,对齐 Android `HomeViewModel` 的权限、门店与位置逻辑。
|
||||
final class HomeViewModel {
|
||||
|
||||
private(set) var currentScenicName = ""
|
||||
private(set) var currentAppRole: AppRoleCode?
|
||||
private(set) var commonMenus: [HomeMenuItem] = []
|
||||
private(set) var storeItem: StoreItem?
|
||||
private(set) var showPermissionDialog = false
|
||||
private(set) var showScenicDialog = false
|
||||
private(set) var showLocationTimeoutDialog = false
|
||||
private(set) var showOnlineStatusDialog = false
|
||||
private(set) var showLocationReportSuccessDialog = false
|
||||
private(set) var isMinimalTopRole = false
|
||||
private(set) var locationInfoText = "定位中..."
|
||||
private(set) var reportTimeText = ""
|
||||
private(set) var isLocationReporting = false
|
||||
private(set) var needsPermissionReload = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
let locationStateStore: HomeLocationStateStore
|
||||
private let appStore: AppStore
|
||||
private let commonMenuStore: HomeCommonMenuStore
|
||||
private let locationProvider: HomeLocationProvider
|
||||
|
||||
private var lastOnlineStatusSwitchTime: Int64 = 0
|
||||
private var lastLocationReportTime: Int64 = 0
|
||||
private var timeoutDialogTriggered = false
|
||||
private var dismissedLocationTimeoutDialog = false
|
||||
private let operationIntervalMillis: Int64 = 30_000
|
||||
|
||||
init(
|
||||
appStore: AppStore = .shared,
|
||||
locationStateStore: HomeLocationStateStore = HomeLocationStateStore(),
|
||||
commonMenuStore: HomeCommonMenuStore = HomeCommonMenuStore(),
|
||||
locationProvider: HomeLocationProvider = HomeLocationProvider()
|
||||
) {
|
||||
self.appStore = appStore
|
||||
self.locationStateStore = locationStateStore
|
||||
self.commonMenuStore = commonMenuStore
|
||||
self.locationProvider = locationProvider
|
||||
self.locationStateStore.onStateChange = { [weak self] in
|
||||
self?.notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
var isOnline: Bool { locationStateStore.isOnline }
|
||||
var countdownDisplayText: String { locationStateStore.countdownDisplayText }
|
||||
var reminderMinutes: Int { locationStateStore.reminderMinutes }
|
||||
|
||||
/// 首次进入首页时加载权限并恢复在线状态。
|
||||
func initialize(api: HomeAPI) async {
|
||||
locationStateStore.restoreStateIfNeeded()
|
||||
refreshLocalDisplayState()
|
||||
await loadPermissions(api: api, force: true)
|
||||
}
|
||||
|
||||
/// 账号切换后标记需要重新拉取权限。
|
||||
func markNeedsPermissionReload() {
|
||||
needsPermissionReload = true
|
||||
}
|
||||
|
||||
/// 按需重新拉取权限。
|
||||
func reloadIfNeeded(api: HomeAPI) async {
|
||||
guard needsPermissionReload else {
|
||||
refreshLocalDisplayState()
|
||||
rebuildCommonMenus()
|
||||
await loadStoreListIfNeeded(api: api)
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
needsPermissionReload = false
|
||||
await loadPermissions(api: api, force: true)
|
||||
}
|
||||
|
||||
/// 拉取 role-permission 并刷新菜单。
|
||||
func loadPermissions(api: HomeAPI, force: Bool = false) async {
|
||||
if !force, !appStore.permissionItems().isEmpty {
|
||||
rebuildCommonMenus()
|
||||
await loadStoreListIfNeeded(api: api)
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let rolePermissionList = try await api.rolePermissions()
|
||||
if rolePermissionList.isEmpty {
|
||||
appStore.savePermissionItems([])
|
||||
showPermissionDialog = true
|
||||
rebuildCommonMenus()
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
|
||||
appStore.saveRolePermissionList(rolePermissionList)
|
||||
let matched = appStore.matchRolePermissionItem(in: rolePermissionList) ?? rolePermissionList[0]
|
||||
if !matched.role.name.isEmpty {
|
||||
appStore.roleName = matched.role.name
|
||||
}
|
||||
appStore.saveRoleScenicList(matched.scenic)
|
||||
let permissions = matched.role.permission.map(HomePermissionItem.init)
|
||||
appStore.savePermissionItems(permissions)
|
||||
showPermissionDialog = false
|
||||
refreshLocalDisplayState()
|
||||
rebuildCommonMenus()
|
||||
await loadStoreListIfNeeded(api: api)
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
appStore.savePermissionItems([])
|
||||
showPermissionDialog = true
|
||||
rebuildCommonMenus()
|
||||
onShowMessage?(error.localizedDescription)
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
/// 店铺管理员加载当前景区门店。
|
||||
func loadStoreListIfNeeded(api: HomeAPI) async {
|
||||
guard currentAppRole == .storeAdmin else {
|
||||
storeItem = nil
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
guard appStore.currentScenicId > 0 else {
|
||||
storeItem = nil
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.storeList()
|
||||
let scenicId = appStore.currentScenicId
|
||||
let savedStoreId = appStore.currentStoreId
|
||||
let savedMatch = savedStoreId > 0 ? response.list.first { $0.id == savedStoreId } : nil
|
||||
let matched = savedMatch ?? response.list.first { $0.scenicId == scenicId }
|
||||
storeItem = matched
|
||||
if let matched {
|
||||
appStore.currentStoreId = matched.id
|
||||
}
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
storeItem = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
/// 评估弹窗展示优先级:权限 > 景区 > 位置超时。
|
||||
func evaluateDialogs(api: HomeAPI) async {
|
||||
if showPermissionDialog {
|
||||
showScenicDialog = false
|
||||
return
|
||||
}
|
||||
let hasScenic = appStore.currentScenicId > 0
|
||||
showScenicDialog = !hasScenic
|
||||
if showScenicDialog || dismissedLocationTimeoutDialog {
|
||||
showLocationTimeoutDialog = false
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
guard !isMinimalTopRole else {
|
||||
showLocationTimeoutDialog = false
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
await checkLocationTimeout(api: api)
|
||||
}
|
||||
|
||||
/// 检查位置上报是否接近超时。
|
||||
func checkLocationTimeout(api: HomeAPI) async {
|
||||
guard reminderMinutes > 0 else {
|
||||
showLocationTimeoutDialog = false
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !staffId.isEmpty else {
|
||||
showLocationTimeoutDialog = true
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let detail = try await api.locationDetail(staffId: staffId)
|
||||
if detail.expireTime <= 0 {
|
||||
showLocationTimeoutDialog = true
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
let currentMillis = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let expireMillis = detail.expireTime * 1000
|
||||
let threshold = expireMillis - Int64(reminderMinutes * 60 * 1000)
|
||||
showLocationTimeoutDialog = currentMillis >= threshold
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
showLocationTimeoutDialog = true
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新景区名与角色展示状态。
|
||||
func refreshLocalDisplayState() {
|
||||
currentScenicName = appStore.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
currentAppRole = appStore.currentAppRole
|
||||
isMinimalTopRole = currentAppRole?.usesHomeMinimalTopLayout ?? false
|
||||
}
|
||||
|
||||
/// 重建常用应用列表。
|
||||
func rebuildCommonMenus() {
|
||||
let permissions = appStore.permissionItems()
|
||||
let allMenus = HomeMenuCatalog.visibleMenus(from: permissions)
|
||||
commonMenus = commonMenuStore.commonMenus(
|
||||
from: allMenus,
|
||||
permissions: permissions,
|
||||
accountScope: appStore.accountCachePrefix,
|
||||
roleCode: appStore.roleCode
|
||||
)
|
||||
}
|
||||
|
||||
func hidePermissionDialog() {
|
||||
showPermissionDialog = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func hideScenicDialog() {
|
||||
showScenicDialog = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func showOnlineStatusSwitchDialog() {
|
||||
showOnlineStatusDialog = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func hideOnlineStatusSwitchDialog() {
|
||||
showOnlineStatusDialog = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func hideLocationTimeoutDialog() {
|
||||
showLocationTimeoutDialog = false
|
||||
dismissedLocationTimeoutDialog = true
|
||||
timeoutDialogTriggered = false
|
||||
locationStateStore.updateOnlineStatus(false)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func hideLocationReportSuccessDialog() {
|
||||
showLocationReportSuccessDialog = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updateReminderMinutes(_ minutes: Int) {
|
||||
locationStateStore.updateReminderMinutes(minutes)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 切换在线/离线并触发位置上报。
|
||||
func switchOnlineStatus(api: HomeAPI) async {
|
||||
let now = currentTimestampMillis()
|
||||
if now - lastOnlineStatusSwitchTime < operationIntervalMillis {
|
||||
let remaining = Int((operationIntervalMillis - (now - lastOnlineStatusSwitchTime)) / 1000)
|
||||
onShowMessage?("操作过于频繁,请\(remaining)秒后再试")
|
||||
hideOnlineStatusSwitchDialog()
|
||||
return
|
||||
}
|
||||
guard appStore.currentScenicId > 0 else {
|
||||
onShowMessage?("请先选择景区")
|
||||
hideOnlineStatusSwitchDialog()
|
||||
return
|
||||
}
|
||||
|
||||
let goingOnline = !isOnline
|
||||
if goingOnline {
|
||||
do {
|
||||
_ = try await locationProvider.requestSnapshot()
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
hideOnlineStatusSwitchDialog()
|
||||
return
|
||||
}
|
||||
locationStateStore.updateOnlineStatus(true)
|
||||
timeoutDialogTriggered = false
|
||||
dismissedLocationTimeoutDialog = false
|
||||
lastOnlineStatusSwitchTime = now
|
||||
onShowMessage?("已切换到在线状态,正在上报位置")
|
||||
hideOnlineStatusSwitchDialog()
|
||||
await reportLocation(api: api, type: 1)
|
||||
} else {
|
||||
locationStateStore.updateOnlineStatus(false)
|
||||
lastOnlineStatusSwitchTime = now
|
||||
onShowMessage?("已切换到离线状态")
|
||||
hideOnlineStatusSwitchDialog()
|
||||
await reportLocation(api: api, type: 3)
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动上报位置。
|
||||
func manualReportLocation(api: HomeAPI) async {
|
||||
let now = currentTimestampMillis()
|
||||
if now - lastLocationReportTime < operationIntervalMillis {
|
||||
let remaining = Int((operationIntervalMillis - (now - lastLocationReportTime)) / 1000)
|
||||
onShowMessage?("操作过于频繁,请\(remaining)秒后再试")
|
||||
return
|
||||
}
|
||||
await reportLocation(api: api, type: 1)
|
||||
}
|
||||
|
||||
/// 位置超时弹窗确认上报。
|
||||
func confirmLocationTimeoutReport(api: HomeAPI) async {
|
||||
showLocationTimeoutDialog = false
|
||||
dismissedLocationTimeoutDialog = false
|
||||
await reportLocation(api: api, type: 1)
|
||||
}
|
||||
|
||||
/// 触发本地倒计时提醒。
|
||||
func triggerLocationTimeoutIfNeeded() {
|
||||
guard locationStateStore.shouldTriggerTimeoutReminder() else { return }
|
||||
guard !timeoutDialogTriggered else { return }
|
||||
timeoutDialogTriggered = true
|
||||
showLocationTimeoutDialog = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 刷新当前位置文案。
|
||||
func refreshLocationInfo() async {
|
||||
isLocationReporting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLocationReporting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
let snapshot = try await locationProvider.requestSnapshot()
|
||||
locationInfoText = snapshot.address.isEmpty
|
||||
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
|
||||
: snapshot.address
|
||||
} catch {
|
||||
locationInfoText = "定位失败"
|
||||
}
|
||||
}
|
||||
|
||||
private func reportLocation(api: HomeAPI, type: Int) async {
|
||||
guard appStore.currentScenicId > 0 else {
|
||||
onShowMessage?("请先选择景区")
|
||||
return
|
||||
}
|
||||
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !staffId.isEmpty else {
|
||||
onShowMessage?("获取用户ID失败")
|
||||
return
|
||||
}
|
||||
|
||||
isLocationReporting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLocationReporting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
let snapshot = try await locationProvider.requestSnapshot()
|
||||
locationInfoText = snapshot.address.isEmpty
|
||||
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
|
||||
: snapshot.address
|
||||
_ = try await api.reportLocation(
|
||||
staffId: staffId,
|
||||
latitude: snapshot.latitude,
|
||||
longitude: snapshot.longitude,
|
||||
address: snapshot.address,
|
||||
type: type,
|
||||
scenicId: String(appStore.currentScenicId)
|
||||
)
|
||||
lastLocationReportTime = currentTimestampMillis()
|
||||
if type != 3 {
|
||||
locationStateStore.startLocationReport()
|
||||
reportTimeText = formattedNow()
|
||||
showLocationReportSuccessDialog = true
|
||||
timeoutDialogTriggered = false
|
||||
dismissedLocationTimeoutDialog = false
|
||||
}
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedNow() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
|
||||
private func currentTimestampMillis() -> Int64 {
|
||||
Int64(Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
@ -6,27 +6,359 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 首页 Tab 根页面。
|
||||
/// 首页 Tab 根页面,按身份与权限展示信息与入口。
|
||||
final class HomeViewController: BaseViewController {
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let viewModel = HomeViewModel()
|
||||
private let homeAPI = NetworkServices.shared.homeAPI
|
||||
|
||||
private let scenicHeaderView = HomeScenicHeaderView()
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let workStatusCardView = HomeWorkStatusCardView()
|
||||
private let locationReportCardView = HomeLocationReportCardView()
|
||||
private let quickActionsView = HomeQuickActionsView()
|
||||
private let storeCardView = HomeStoreCardView()
|
||||
private let commonAppsGridView = HomeCommonAppsGridView()
|
||||
|
||||
private var hasInitialized = false
|
||||
private var countdownTimer: Timer?
|
||||
private var activeDialog: HomeDialogKind?
|
||||
|
||||
override func setupNavigationBar() {
|
||||
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
titleLabel.text = "首页"
|
||||
titleLabel.font = .systemFont(ofSize: 24, weight: .medium)
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.textColor = AppColor.text333
|
||||
view.addSubview(titleLabel)
|
||||
view.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 12
|
||||
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
view.addSubview(scenicHeaderView)
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
|
||||
contentStack.addArrangedSubview(workStatusCardView)
|
||||
contentStack.addArrangedSubview(locationReportCardView)
|
||||
contentStack.addArrangedSubview(quickActionsView)
|
||||
contentStack.addArrangedSubview(storeCardView)
|
||||
contentStack.addArrangedSubview(commonAppsGridView)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
scenicHeaderView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(52)
|
||||
}
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.equalTo(scenicHeaderView.snp.bottom)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(scrollView.snp.width).offset(-32)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
|
||||
scenicHeaderView.onTap = { [weak self] in self?.presentScenicSelection() }
|
||||
workStatusCardView.onOnlineTap = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() }
|
||||
workStatusCardView.onReminderTap = { [weak self] in self?.presentReminderPicker() }
|
||||
locationReportCardView.onReportTap = { [weak self] in
|
||||
Task { await self?.viewModel.manualReportLocation(api: self?.homeAPI ?? NetworkServices.shared.homeAPI) }
|
||||
}
|
||||
quickActionsView.onCollectPayment = { [weak self] in self?.showToast("功能开发中") }
|
||||
quickActionsView.onSubmitTask = { [weak self] in self?.showToast("功能开发中") }
|
||||
quickActionsView.onToggleOnline = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() }
|
||||
commonAppsGridView.onMenuSelected = { [weak self] menu in
|
||||
guard let self else { return }
|
||||
HomeRouteHandler.open(uri: menu.uri, from: self, storeItem: self.viewModel.storeItem)
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleAccountDidSwitch),
|
||||
name: NotificationName.accountDidSwitch,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleScenicDidChange),
|
||||
name: NotificationName.scenicDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
if !hasInitialized {
|
||||
hasInitialized = true
|
||||
Task { await initializeHome() }
|
||||
} else {
|
||||
Task { await viewModel.reloadIfNeeded(api: homeAPI) }
|
||||
}
|
||||
startCountdownTimer()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
countdownTimer?.invalidate()
|
||||
countdownTimer = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
private func initializeHome() async {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
await viewModel.initialize(api: homeAPI)
|
||||
applyViewModel()
|
||||
await evaluateDialogsWithDelay()
|
||||
await viewModel.refreshLocationInfo()
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
private func evaluateDialogsWithDelay() async {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
await viewModel.evaluateDialogs(api: homeAPI)
|
||||
applyViewModel()
|
||||
presentDialogsIfNeeded()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
scenicHeaderView.apply(scenicName: viewModel.currentScenicName)
|
||||
let showWorkBlock = !viewModel.isMinimalTopRole
|
||||
workStatusCardView.isHidden = !showWorkBlock
|
||||
locationReportCardView.isHidden = !showWorkBlock
|
||||
quickActionsView.isHidden = !showWorkBlock
|
||||
|
||||
if showWorkBlock {
|
||||
workStatusCardView.apply(
|
||||
isOnline: viewModel.isOnline,
|
||||
countdownText: viewModel.countdownDisplayText,
|
||||
reminderMinutes: viewModel.reminderMinutes
|
||||
)
|
||||
locationReportCardView.apply(
|
||||
address: viewModel.locationInfoText,
|
||||
isReporting: viewModel.isLocationReporting
|
||||
)
|
||||
quickActionsView.apply(isOnline: viewModel.isOnline)
|
||||
}
|
||||
|
||||
let showStore = viewModel.currentAppRole == .storeAdmin && viewModel.storeItem != nil
|
||||
storeCardView.isHidden = !showStore
|
||||
if showStore, let store = viewModel.storeItem {
|
||||
storeCardView.apply(store: store)
|
||||
}
|
||||
|
||||
commonAppsGridView.apply(menus: viewModel.commonMenus)
|
||||
presentDialogsIfNeeded()
|
||||
}
|
||||
|
||||
private func presentDialogsIfNeeded() {
|
||||
if viewModel.showPermissionDialog {
|
||||
presentDialog(.permission)
|
||||
return
|
||||
}
|
||||
if viewModel.showScenicDialog {
|
||||
presentDialog(.scenic)
|
||||
return
|
||||
}
|
||||
if viewModel.showOnlineStatusDialog {
|
||||
presentDialog(.onlineStatus)
|
||||
return
|
||||
}
|
||||
if viewModel.showLocationTimeoutDialog {
|
||||
presentDialog(.locationTimeout)
|
||||
return
|
||||
}
|
||||
if viewModel.showLocationReportSuccessDialog {
|
||||
presentDialog(.locationSuccess)
|
||||
return
|
||||
}
|
||||
activeDialog = nil
|
||||
}
|
||||
|
||||
private enum HomeDialogKind {
|
||||
case permission
|
||||
case scenic
|
||||
case onlineStatus
|
||||
case locationTimeout
|
||||
case locationSuccess
|
||||
}
|
||||
|
||||
private func presentDialog(_ kind: HomeDialogKind) {
|
||||
guard activeDialog != kind, presentedViewController == nil else { return }
|
||||
activeDialog = kind
|
||||
switch kind {
|
||||
case .permission: presentPermissionDialog()
|
||||
case .scenic: presentScenicDialog()
|
||||
case .onlineStatus: presentOnlineStatusDialog()
|
||||
case .locationTimeout: presentLocationTimeoutDialog()
|
||||
case .locationSuccess: presentLocationSuccessDialog()
|
||||
}
|
||||
}
|
||||
|
||||
private func presentPermissionDialog() {
|
||||
let alert = UIAlertController(
|
||||
title: "权限提示",
|
||||
message: "您还没有权限,请先申请权限",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.hidePermissionDialog()
|
||||
self?.activeDialog = nil
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "去申请", style: .default) { [weak self] _ in
|
||||
self?.showToast("功能开发中")
|
||||
self?.activeDialog = nil
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentScenicDialog() {
|
||||
let alert = UIAlertController(
|
||||
title: "请选择景区",
|
||||
message: "使用首页功能前需要先选择当前景区",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.hideScenicDialog()
|
||||
self?.activeDialog = nil
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "去选择", style: .default) { [weak self] _ in
|
||||
self?.viewModel.hideScenicDialog()
|
||||
self?.activeDialog = nil
|
||||
self?.presentScenicSelection()
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentOnlineStatusDialog() {
|
||||
let goingOnline = !viewModel.isOnline
|
||||
let alert = UIAlertController(
|
||||
title: goingOnline ? "切换在线" : "切换离线",
|
||||
message: goingOnline ? "确认切换到在线状态并上报位置?" : "确认切换到离线状态?",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.hideOnlineStatusSwitchDialog()
|
||||
self?.activeDialog = nil
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.activeDialog = nil
|
||||
Task { await self.viewModel.switchOnlineStatus(api: self.homeAPI) }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentLocationTimeoutDialog() {
|
||||
let alert = UIAlertController(
|
||||
title: "位置上报提醒",
|
||||
message: "您的位置上报即将超时,请立即上报",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.hideLocationTimeoutDialog()
|
||||
self?.activeDialog = nil
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "立即上报", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.activeDialog = nil
|
||||
Task { await self.viewModel.confirmLocationTimeoutReport(api: self.homeAPI) }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentLocationSuccessDialog() {
|
||||
let alert = UIAlertController(
|
||||
title: "上报成功",
|
||||
message: "上报时间:\(viewModel.reportTimeText)",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
|
||||
self?.viewModel.hideLocationReportSuccessDialog()
|
||||
self?.activeDialog = nil
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentReminderPicker() {
|
||||
let alert = UIAlertController(title: "提前提醒", message: "选择位置超时提前提醒时间", preferredStyle: .actionSheet)
|
||||
[0, 5, 10, 15, 30].forEach { minutes in
|
||||
let title = minutes == 0 ? "不提醒" : "提前\(minutes)分钟"
|
||||
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||
self?.viewModel.updateReminderMinutes(minutes)
|
||||
})
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentScenicSelection() {
|
||||
let scenicList = AppStore.shared.roleScenicList()
|
||||
guard !scenicList.isEmpty else {
|
||||
showToast("暂无可选景区")
|
||||
return
|
||||
}
|
||||
let controller = ScenicSelectionViewController(scenicList: scenicList)
|
||||
controller.onSelected = { [weak self] in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
await self.viewModel.reloadIfNeeded(api: self.homeAPI)
|
||||
await self.viewModel.loadStoreListIfNeeded(api: self.homeAPI)
|
||||
self.applyViewModel()
|
||||
await self.evaluateDialogsWithDelay()
|
||||
}
|
||||
}
|
||||
let nav = UINavigationController(rootViewController: controller)
|
||||
nav.modalPresentationStyle = .pageSheet
|
||||
present(nav, animated: true)
|
||||
}
|
||||
|
||||
private func startCountdownTimer() {
|
||||
countdownTimer?.invalidate()
|
||||
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.viewModel.triggerLocationTimeoutIfNeeded()
|
||||
if !self.viewModel.isMinimalTopRole {
|
||||
self.workStatusCardView.apply(
|
||||
isOnline: self.viewModel.isOnline,
|
||||
countdownText: self.viewModel.countdownDisplayText,
|
||||
reminderMinutes: self.viewModel.reminderMinutes
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleAccountDidSwitch() {
|
||||
viewModel.markNeedsPermissionReload()
|
||||
Task {
|
||||
await viewModel.reloadIfNeeded(api: homeAPI)
|
||||
applyViewModel()
|
||||
await evaluateDialogsWithDelay()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleScenicDidChange() {
|
||||
viewModel.refreshLocalDisplayState()
|
||||
Task {
|
||||
await viewModel.loadStoreListIfNeeded(api: homeAPI)
|
||||
applyViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
85
suixinkan/UI/Home/ScenicSelectionViewController.swift
Normal file
85
suixinkan/UI/Home/ScenicSelectionViewController.swift
Normal file
@ -0,0 +1,85 @@
|
||||
//
|
||||
// ScenicSelectionViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 景区选择页,使用 role-permission 返回的景区列表。
|
||||
final class ScenicSelectionViewController: BaseViewController {
|
||||
|
||||
var onSelected: (() -> Void)?
|
||||
|
||||
private let scenicList: [ScenicInfo]
|
||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
|
||||
init(scenicList: [ScenicInfo]) {
|
||||
self.scenicList = scenicList
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "选择景区"
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||
barButtonSystemItem: .close,
|
||||
target: self,
|
||||
action: #selector(closeTapped)
|
||||
)
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
|
||||
view.addSubview(tableView)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func closeTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension ScenicSelectionViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
scenicList.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
|
||||
var config = cell.defaultContentConfiguration()
|
||||
config.text = scenicList[indexPath.row].name
|
||||
cell.contentConfiguration = config
|
||||
cell.accessoryType = scenicList[indexPath.row].id == AppStore.shared.currentScenicId ? .checkmark : .none
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
let scenic = scenicList[indexPath.row]
|
||||
AppStore.shared.currentScenicId = scenic.id
|
||||
AppStore.shared.currentScenicName = scenic.name
|
||||
NotificationCenter.default.post(
|
||||
name: NotificationName.scenicDidChange,
|
||||
object: nil,
|
||||
userInfo: [
|
||||
NotificationUserInfoKey.scenicId: scenic.id,
|
||||
NotificationUserInfoKey.scenicName: scenic.name,
|
||||
]
|
||||
)
|
||||
onSelected?()
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
132
suixinkan/UI/Home/Views/HomeCommonAppsGridView.swift
Normal file
132
suixinkan/UI/Home/Views/HomeCommonAppsGridView.swift
Normal file
@ -0,0 +1,132 @@
|
||||
//
|
||||
// HomeCommonAppsGridView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 首页常用应用网格。
|
||||
final class HomeCommonAppsGridView: UIView {
|
||||
|
||||
var onMenuSelected: ((HomeMenuItem) -> Void)?
|
||||
|
||||
private enum Section { case main }
|
||||
private enum Item: Hashable { case menu(HomeMenuItem) }
|
||||
|
||||
private var menus: [HomeMenuItem] = []
|
||||
private let titleLabel = UILabel()
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let itemSize = NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(0.25),
|
||||
heightDimension: .absolute(88)
|
||||
)
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
let groupSize = NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .absolute(88)
|
||||
)
|
||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
let layout = UICollectionViewCompositionalLayout(section: section)
|
||||
return UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
}()
|
||||
|
||||
private lazy var dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
|
||||
collectionView, indexPath, item in
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: HomeMenuCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! HomeMenuCell
|
||||
if case let .menu(menu) = item {
|
||||
cell.apply(menu: menu)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
titleLabel.text = "常用应用"
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .bold)
|
||||
titleLabel.textColor = AppColor.text333
|
||||
|
||||
collectionView.backgroundColor = .clear
|
||||
collectionView.delegate = self
|
||||
collectionView.register(HomeMenuCell.self, forCellWithReuseIdentifier: HomeMenuCell.reuseIdentifier)
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(collectionView)
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
}
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(88)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(menus: [HomeMenuItem]) {
|
||||
self.menus = menus + [.moreFunctions]
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(self.menus.map { Item.menu($0) })
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeCommonAppsGridView: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard indexPath.item < menus.count else { return }
|
||||
onMenuSelected?(menus[indexPath.item])
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页菜单 cell。
|
||||
final class HomeMenuCell: UICollectionViewCell {
|
||||
|
||||
static let reuseIdentifier = "HomeMenuCell"
|
||||
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
iconView.tintColor = AppColor.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: 12)
|
||||
titleLabel.textColor = AppColor.text333
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.numberOfLines = 2
|
||||
|
||||
contentView.addSubview(iconView)
|
||||
contentView.addSubview(titleLabel)
|
||||
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(8)
|
||||
make.centerX.equalToSuperview()
|
||||
make.width.height.equalTo(28)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(iconView.snp.bottom).offset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(4)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(menu: HomeMenuItem) {
|
||||
iconView.image = UIImage(systemName: menu.iconName)
|
||||
titleLabel.text = menu.title
|
||||
}
|
||||
}
|
||||
70
suixinkan/UI/Home/Views/HomeLocationReportCardView.swift
Normal file
70
suixinkan/UI/Home/Views/HomeLocationReportCardView.swift
Normal file
@ -0,0 +1,70 @@
|
||||
//
|
||||
// HomeLocationReportCardView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 首页位置上报卡片。
|
||||
final class HomeLocationReportCardView: UIView {
|
||||
|
||||
var onReportTap: (() -> Void)?
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let addressLabel = UILabel()
|
||||
private let reportButton = UIButton(type: .system)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 10
|
||||
|
||||
titleLabel.text = "位置上报"
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
titleLabel.textColor = AppColor.text333
|
||||
|
||||
addressLabel.font = .systemFont(ofSize: 13)
|
||||
addressLabel.textColor = AppColor.text666
|
||||
addressLabel.numberOfLines = 2
|
||||
|
||||
reportButton.setTitle("立即上报", for: .normal)
|
||||
reportButton.setTitleColor(.white, for: .normal)
|
||||
reportButton.backgroundColor = AppColor.primary
|
||||
reportButton.layer.cornerRadius = 6
|
||||
reportButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
reportButton.contentEdgeInsets = UIEdgeInsets(top: 8, left: 14, bottom: 8, right: 14)
|
||||
reportButton.addTarget(self, action: #selector(reportTapped), for: .touchUpInside)
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(addressLabel)
|
||||
addSubview(reportButton)
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().offset(15)
|
||||
}
|
||||
addressLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||
make.leading.equalToSuperview().offset(15)
|
||||
make.bottom.equalToSuperview().offset(-15)
|
||||
make.trailing.lessThanOrEqualTo(reportButton.snp.leading).offset(-12)
|
||||
}
|
||||
reportButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-15)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(address: String, isReporting: Bool) {
|
||||
addressLabel.text = address
|
||||
reportButton.isEnabled = !isReporting
|
||||
reportButton.alpha = isReporting ? 0.6 : 1
|
||||
}
|
||||
|
||||
@objc private func reportTapped() { onReportTap?() }
|
||||
}
|
||||
86
suixinkan/UI/Home/Views/HomeQuickActionsView.swift
Normal file
86
suixinkan/UI/Home/Views/HomeQuickActionsView.swift
Normal file
@ -0,0 +1,86 @@
|
||||
//
|
||||
// HomeQuickActionsView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 首页快捷操作按钮区。
|
||||
final class HomeQuickActionsView: UIView {
|
||||
|
||||
var onCollectPayment: (() -> Void)?
|
||||
var onSubmitTask: (() -> Void)?
|
||||
var onToggleOnline: (() -> Void)?
|
||||
|
||||
private let stackView = UIStackView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .fillEqually
|
||||
stackView.spacing = 12
|
||||
addSubview(stackView)
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
make.height.equalTo(72)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(isOnline: Bool) {
|
||||
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
stackView.addArrangedSubview(makeActionButton(title: "立即收款", icon: "yensign.circle", action: #selector(collectPaymentTapped)))
|
||||
stackView.addArrangedSubview(makeActionButton(title: "提交任务", icon: "doc.text", action: #selector(submitTaskTapped)))
|
||||
stackView.addArrangedSubview(makeActionButton(
|
||||
title: isOnline ? "在线" : "离线",
|
||||
icon: isOnline ? "dot.radiowaves.left.and.right" : "moon",
|
||||
action: #selector(toggleOnlineTapped)
|
||||
))
|
||||
}
|
||||
|
||||
private func makeActionButton(title: String, icon: String, action: Selector) -> UIView {
|
||||
let container = UIView()
|
||||
container.backgroundColor = .white
|
||||
container.layer.cornerRadius = 10
|
||||
|
||||
let button = UIButton(type: .system)
|
||||
button.addTarget(self, action: action, for: .touchUpInside)
|
||||
container.addSubview(button)
|
||||
button.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
let imageView = UIImageView(image: UIImage(systemName: icon))
|
||||
imageView.tintColor = AppColor.primary
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
label.textColor = AppColor.text333
|
||||
label.textAlignment = .center
|
||||
|
||||
let column = UIStackView(arrangedSubviews: [imageView, label])
|
||||
column.axis = .vertical
|
||||
column.alignment = .center
|
||||
column.spacing = 6
|
||||
column.isUserInteractionEnabled = false
|
||||
container.addSubview(column)
|
||||
column.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(22)
|
||||
}
|
||||
return container
|
||||
}
|
||||
|
||||
@objc private func collectPaymentTapped() { onCollectPayment?() }
|
||||
@objc private func submitTaskTapped() { onSubmitTask?() }
|
||||
@objc private func toggleOnlineTapped() { onToggleOnline?() }
|
||||
}
|
||||
56
suixinkan/UI/Home/Views/HomeScenicHeaderView.swift
Normal file
56
suixinkan/UI/Home/Views/HomeScenicHeaderView.swift
Normal file
@ -0,0 +1,56 @@
|
||||
//
|
||||
// HomeScenicHeaderView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 首页顶部景区选择条。
|
||||
final class HomeScenicHeaderView: UIView {
|
||||
|
||||
var onTap: (() -> Void)?
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let arrowView = UIImageView(image: UIImage(systemName: "chevron.down"))
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
titleLabel.textColor = AppColor.text333
|
||||
arrowView.tintColor = AppColor.text666
|
||||
arrowView.contentMode = .scaleAspectFit
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(arrowView)
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.trailing.lessThanOrEqualTo(arrowView.snp.leading).offset(-8)
|
||||
}
|
||||
arrowView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(16)
|
||||
}
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(scenicName: String) {
|
||||
let trimmed = scenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
titleLabel.text = trimmed.isEmpty ? "请选择景区" : trimmed
|
||||
}
|
||||
|
||||
@objc private func handleTap() {
|
||||
onTap?()
|
||||
}
|
||||
}
|
||||
60
suixinkan/UI/Home/Views/HomeStoreCardView.swift
Normal file
60
suixinkan/UI/Home/Views/HomeStoreCardView.swift
Normal file
@ -0,0 +1,60 @@
|
||||
//
|
||||
// HomeStoreCardView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 店铺管理员门店信息卡片。
|
||||
final class HomeStoreCardView: UIView {
|
||||
|
||||
private let nameLabel = UILabel()
|
||||
private let addressLabel = UILabel()
|
||||
private let statusLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 10
|
||||
|
||||
nameLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
nameLabel.textColor = AppColor.text333
|
||||
|
||||
addressLabel.font = .systemFont(ofSize: 13)
|
||||
addressLabel.textColor = AppColor.text666
|
||||
addressLabel.numberOfLines = 2
|
||||
|
||||
statusLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
statusLabel.textColor = AppColor.primary
|
||||
|
||||
addSubview(nameLabel)
|
||||
addSubview(addressLabel)
|
||||
addSubview(statusLabel)
|
||||
|
||||
nameLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().offset(15)
|
||||
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8)
|
||||
}
|
||||
statusLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(15)
|
||||
make.trailing.equalToSuperview().offset(-15)
|
||||
}
|
||||
addressLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(nameLabel.snp.bottom).offset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(15)
|
||||
make.bottom.equalToSuperview().offset(-15)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(store: StoreItem) {
|
||||
nameLabel.text = store.name
|
||||
addressLabel.text = store.address
|
||||
statusLabel.text = store.statusText.isEmpty ? "营业中" : store.statusText
|
||||
}
|
||||
}
|
||||
77
suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift
Normal file
77
suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift
Normal file
@ -0,0 +1,77 @@
|
||||
//
|
||||
// HomeWorkStatusCardView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 首页在线状态与倒计时卡片。
|
||||
final class HomeWorkStatusCardView: UIView {
|
||||
|
||||
var onOnlineTap: (() -> Void)?
|
||||
var onReminderTap: (() -> Void)?
|
||||
|
||||
private let onlineButton = UIButton(type: .system)
|
||||
private let countdownLabel = UILabel()
|
||||
private let reminderButton = UIButton(type: .system)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 10
|
||||
|
||||
onlineButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
onlineButton.layer.cornerRadius = 4
|
||||
onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
|
||||
onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside)
|
||||
|
||||
countdownLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
countdownLabel.textColor = AppColor.primary
|
||||
|
||||
reminderButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
reminderButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside)
|
||||
|
||||
addSubview(onlineButton)
|
||||
addSubview(countdownLabel)
|
||||
addSubview(reminderButton)
|
||||
|
||||
onlineButton.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(15)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
countdownLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
reminderButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-15)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(52)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(isOnline: Bool, countdownText: String, reminderMinutes: Int) {
|
||||
onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal)
|
||||
if isOnline {
|
||||
onlineButton.backgroundColor = UIColor(hex: 0x22C55E)
|
||||
onlineButton.setTitleColor(UIColor(hex: 0xF0FDF4), for: .normal)
|
||||
} else {
|
||||
onlineButton.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
onlineButton.setTitleColor(UIColor(hex: 0x7B8EAA), for: .normal)
|
||||
}
|
||||
countdownLabel.text = countdownText
|
||||
let reminderTitle = reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟"
|
||||
reminderButton.setTitle(reminderTitle, for: .normal)
|
||||
}
|
||||
|
||||
@objc private func onlineTapped() { onOnlineTap?() }
|
||||
@objc private func reminderTapped() { onReminderTap?() }
|
||||
}
|
||||
50
suixinkanTests/HomeCommonMenuStoreTests.swift
Normal file
50
suixinkanTests/HomeCommonMenuStoreTests.swift
Normal file
@ -0,0 +1,50 @@
|
||||
//
|
||||
// HomeCommonMenuStoreTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 常用应用持久化测试。
|
||||
final class HomeCommonMenuStoreTests: XCTestCase {
|
||||
|
||||
private var defaults: UserDefaults!
|
||||
private var store: HomeCommonMenuStore!
|
||||
|
||||
override func setUp() {
|
||||
defaults = UserDefaults(suiteName: "HomeCommonMenuStoreTests")!
|
||||
defaults.removePersistentDomain(forName: "HomeCommonMenuStoreTests")
|
||||
store = HomeCommonMenuStore(defaults: defaults)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: "HomeCommonMenuStoreTests")
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testDefaultCommonURIsTakeFirstFour() {
|
||||
let permissions = (1...6).map {
|
||||
HomePermissionItem(id: "\($0)", name: "菜单\($0)", uri: "uri_\($0)")
|
||||
}
|
||||
let uris = HomeCommonMenuStore.defaultCommonURIs(from: permissions)
|
||||
XCTAssertEqual(uris, ["uri_1", "uri_2", "uri_3", "uri_4"])
|
||||
}
|
||||
|
||||
func testSavedCommonURIsOverrideDefault() {
|
||||
let permissions = [
|
||||
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
|
||||
HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"),
|
||||
HomePermissionItem(id: "3", name: "任务", uri: "task_management"),
|
||||
]
|
||||
let allMenus = HomeMenuCatalog.visibleMenus(from: permissions)
|
||||
store.saveCommonURIs(["cloud_management"], accountScope: "u1_scenic_user", roleCode: "photographer")
|
||||
let common = store.commonMenus(
|
||||
from: allMenus,
|
||||
permissions: permissions,
|
||||
accountScope: "u1_scenic_user",
|
||||
roleCode: "photographer"
|
||||
)
|
||||
XCTAssertEqual(common.map(\.uri), ["cloud_management"])
|
||||
}
|
||||
}
|
||||
66
suixinkanTests/HomeLocationStateStoreTests.swift
Normal file
66
suixinkanTests/HomeLocationStateStoreTests.swift
Normal file
@ -0,0 +1,66 @@
|
||||
//
|
||||
// HomeLocationStateStoreTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 首页在线状态存储测试。
|
||||
final class HomeLocationStateStoreTests: XCTestCase {
|
||||
|
||||
private var appStore: AppStore!
|
||||
|
||||
override func setUp() {
|
||||
let defaults = UserDefaults(suiteName: "HomeLocationStateStoreTests")!
|
||||
defaults.removePersistentDomain(forName: "HomeLocationStateStoreTests")
|
||||
appStore = AppStore(defaults: defaults)
|
||||
appStore.userId = "1001"
|
||||
appStore.accountType = "scenic_user"
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
appStore.logout()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testExpiredOnlineSessionIsClearedOnRestore() {
|
||||
let expiredTimestamp = Int64(Date().timeIntervalSince1970 * 1000) - HomeLocationStateStore.onlineDurationMillis - 1_000
|
||||
appStore.onlineStatus = true
|
||||
appStore.lastLocationReportTime = expiredTimestamp
|
||||
|
||||
let stateStore = HomeLocationStateStore(store: appStore)
|
||||
stateStore.restoreStateIfNeeded()
|
||||
|
||||
XCTAssertFalse(stateStore.isOnline)
|
||||
XCTAssertFalse(appStore.onlineStatus)
|
||||
XCTAssertEqual(appStore.lastLocationReportTime, 0)
|
||||
}
|
||||
|
||||
func testActiveOnlineSessionRestoresCountdown() {
|
||||
let recentTimestamp = Int64(Date().timeIntervalSince1970 * 1000) - 60_000
|
||||
appStore.onlineStatus = true
|
||||
appStore.lastLocationReportTime = recentTimestamp
|
||||
|
||||
let stateStore = HomeLocationStateStore(store: appStore)
|
||||
stateStore.restoreStateIfNeeded()
|
||||
|
||||
XCTAssertTrue(stateStore.isOnline)
|
||||
XCTAssertGreaterThan(stateStore.nextReportCountdownSeconds, 0)
|
||||
}
|
||||
|
||||
func testFormattedCountdownText() {
|
||||
let stateStore = HomeLocationStateStore(store: appStore)
|
||||
XCTAssertEqual(stateStore.formattedCountdown(from: 3661), "01:01:01")
|
||||
}
|
||||
|
||||
func testShouldTriggerTimeoutReminderWithinWindow() {
|
||||
appStore.locationReminderMinutes = 10
|
||||
let stateStore = HomeLocationStateStore(store: appStore)
|
||||
stateStore.startLocationReport(at: Int64(Date().timeIntervalSince1970 * 1000))
|
||||
stateStore.updateReminderMinutes(10)
|
||||
// Force countdown into reminder window via reflection of internal state is not available;
|
||||
// verify helper with manual expectation on threshold logic.
|
||||
XCTAssertFalse(stateStore.shouldTriggerTimeoutReminder())
|
||||
}
|
||||
}
|
||||
125
suixinkanTests/HomeViewModelTests.swift
Normal file
125
suixinkanTests/HomeViewModelTests.swift
Normal file
@ -0,0 +1,125 @@
|
||||
//
|
||||
// HomeViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 首页 ViewModel 测试。
|
||||
final class HomeViewModelTests: XCTestCase {
|
||||
|
||||
private var appStore: AppStore!
|
||||
|
||||
override func setUp() {
|
||||
let defaults = UserDefaults(suiteName: "HomeViewModelTests")!
|
||||
defaults.removePersistentDomain(forName: "HomeViewModelTests")
|
||||
appStore = AppStore(defaults: defaults)
|
||||
appStore.userId = "2001"
|
||||
appStore.accountType = "scenic_user"
|
||||
appStore.roleCode = AppRoleCode.photographer.rawValue
|
||||
appStore.roleName = "摄影师"
|
||||
appStore.currentScenicId = 10
|
||||
appStore.currentScenicName = "测试景区"
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
appStore.logout()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testMinimalTopRoleFlag() {
|
||||
appStore.roleCode = AppRoleCode.storeAdmin.rawValue
|
||||
let viewModel = HomeViewModel(appStore: appStore)
|
||||
viewModel.refreshLocalDisplayState()
|
||||
XCTAssertTrue(viewModel.isMinimalTopRole)
|
||||
|
||||
appStore.roleCode = AppRoleCode.photographer.rawValue
|
||||
viewModel.refreshLocalDisplayState()
|
||||
XCTAssertFalse(viewModel.isMinimalTopRole)
|
||||
}
|
||||
|
||||
func testRebuildCommonMenusFiltersUnknownURIs() {
|
||||
appStore.savePermissionItems([
|
||||
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
|
||||
HomePermissionItem(id: "2", name: "未知", uri: "unknown_uri"),
|
||||
])
|
||||
let viewModel = HomeViewModel(appStore: appStore)
|
||||
viewModel.rebuildCommonMenus()
|
||||
XCTAssertEqual(viewModel.commonMenus.map(\.uri), ["wallet"])
|
||||
}
|
||||
|
||||
func testEmptyRolePermissionShowsPermissionDialog() async throws {
|
||||
let responseData = try TestJSON.envelope(data: [RolePermissionResponse]())
|
||||
let session = MockURLSession(responses: [responseData])
|
||||
let api = HomeAPI(client: APIClient(session: session))
|
||||
let viewModel = HomeViewModel(appStore: appStore)
|
||||
|
||||
await viewModel.loadPermissions(api: api, force: true)
|
||||
|
||||
XCTAssertTrue(viewModel.showPermissionDialog)
|
||||
XCTAssertTrue(viewModel.commonMenus.isEmpty)
|
||||
}
|
||||
|
||||
func testRolePermissionBuildsCommonMenusAndStoreCardRole() async throws {
|
||||
appStore.roleCode = AppRoleCode.storeAdmin.rawValue
|
||||
let permissionResponse = RolePermissionResponse(
|
||||
role: RoleInfo(
|
||||
id: 46,
|
||||
name: "店铺管理员",
|
||||
roleCode: AppRoleCode.storeAdmin.rawValue,
|
||||
notes: nil,
|
||||
permission: [
|
||||
PermissionItem(id: 1, pid: 0, name: "钱包", uri: "wallet", iconSrc: nil, children: nil),
|
||||
PermissionItem(id: 2, pid: 0, name: "云盘", uri: "cloud_management", iconSrc: nil, children: nil),
|
||||
]
|
||||
),
|
||||
scenic: [ScenicInfo(id: 10, name: "测试景区", status: 1)]
|
||||
)
|
||||
let storeResponse = StoreListResponse(
|
||||
total: 1,
|
||||
list: [StoreItem(id: 5, scenicId: 10, name: "测试门店", address: "地址", status: 1, statusText: "营业中")]
|
||||
)
|
||||
|
||||
let permissionJSON = try TestJSON.envelope(data: [permissionResponse])
|
||||
let storeJSON = try TestJSON.envelope(data: storeResponse)
|
||||
let session = MockURLSession(responses: [permissionJSON, storeJSON])
|
||||
let api = HomeAPI(client: APIClient(session: session))
|
||||
let viewModel = HomeViewModel(appStore: appStore)
|
||||
|
||||
await viewModel.loadPermissions(api: api, force: true)
|
||||
|
||||
XCTAssertFalse(viewModel.showPermissionDialog)
|
||||
XCTAssertTrue(viewModel.isMinimalTopRole)
|
||||
XCTAssertEqual(viewModel.commonMenus.count, 2)
|
||||
XCTAssertEqual(viewModel.storeItem?.name, "测试门店")
|
||||
}
|
||||
|
||||
func testMarkNeedsPermissionReload() async throws {
|
||||
let permissionResponse = RolePermissionResponse(
|
||||
role: RoleInfo(
|
||||
id: 41,
|
||||
name: "摄影师",
|
||||
roleCode: AppRoleCode.photographer.rawValue,
|
||||
notes: nil,
|
||||
permission: [
|
||||
PermissionItem(id: 1, pid: 0, name: "钱包", uri: "wallet", iconSrc: nil, children: nil),
|
||||
]
|
||||
),
|
||||
scenic: []
|
||||
)
|
||||
appStore.savePermissionItems([HomePermissionItem(id: "1", name: "钱包", uri: "wallet")])
|
||||
|
||||
let permissionJSON = try TestJSON.envelope(data: [permissionResponse])
|
||||
let session = MockURLSession(responses: [permissionJSON])
|
||||
let api = HomeAPI(client: APIClient(session: session))
|
||||
let viewModel = HomeViewModel(appStore: appStore)
|
||||
|
||||
viewModel.markNeedsPermissionReload()
|
||||
await viewModel.reloadIfNeeded(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.commonMenus.map(\.uri), ["wallet"])
|
||||
XCTAssertEqual(session.requests.count, 1)
|
||||
}
|
||||
}
|
||||
52
suixinkanTests/RolePermissionMatcherTests.swift
Normal file
52
suixinkanTests/RolePermissionMatcherTests.swift
Normal file
@ -0,0 +1,52 @@
|
||||
//
|
||||
// RolePermissionMatcherTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 角色权限匹配测试。
|
||||
final class RolePermissionMatcherTests: XCTestCase {
|
||||
|
||||
private func makeItem(code: String, id: Int, name: String) -> RolePermissionResponse {
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: id, name: name, roleCode: code, notes: nil, permission: []),
|
||||
scenic: []
|
||||
)
|
||||
}
|
||||
|
||||
func testMatchByRoleCodeHasHighestPriority() {
|
||||
let list = [
|
||||
makeItem(code: "photographer", id: 41, name: "摄影师"),
|
||||
makeItem(code: "store_admin", id: 46, name: "店铺管理员"),
|
||||
]
|
||||
let matched = RolePermissionMatcher.match(
|
||||
in: list,
|
||||
roleCode: AppRoleCode.storeAdmin.rawValue,
|
||||
roleName: "摄影师"
|
||||
)
|
||||
XCTAssertEqual(matched?.role.roleCode, AppRoleCode.storeAdmin.rawValue)
|
||||
}
|
||||
|
||||
func testMatchByLegacyRoleIdWhenCodeMissing() {
|
||||
let list = [makeItem(code: "", id: 53, name: "景区管理员")]
|
||||
let matched = RolePermissionMatcher.match(
|
||||
in: list,
|
||||
roleCode: "",
|
||||
roleName: "",
|
||||
legacyRoleId: 53
|
||||
)
|
||||
XCTAssertEqual(matched?.role.id, 53)
|
||||
}
|
||||
|
||||
func testMatchByRoleNameWhenCodeAndLegacyMissing() {
|
||||
let list = [makeItem(code: "", id: 99, name: "剪辑师")]
|
||||
let matched = RolePermissionMatcher.match(
|
||||
in: list,
|
||||
roleCode: "",
|
||||
roleName: "剪辑师"
|
||||
)
|
||||
XCTAssertEqual(matched?.role.name, "剪辑师")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user