实现权限驱动的首页 Tab 并对齐 Android
This commit is contained in:
@ -15,6 +15,7 @@ final class NetworkServices {
|
|||||||
let profileAPI: ProfileAPI
|
let profileAPI: ProfileAPI
|
||||||
let statisticsAPI: StatisticsAPI
|
let statisticsAPI: StatisticsAPI
|
||||||
let orderAPI: OrderAPI
|
let orderAPI: OrderAPI
|
||||||
|
let homeAPI: HomeAPI
|
||||||
let uploadAPI: UploadAPI
|
let uploadAPI: UploadAPI
|
||||||
let ossUploadService: OSSUploadService
|
let ossUploadService: OSSUploadService
|
||||||
|
|
||||||
@ -25,6 +26,7 @@ final class NetworkServices {
|
|||||||
profileAPI = ProfileAPI(client: client)
|
profileAPI = ProfileAPI(client: client)
|
||||||
statisticsAPI = StatisticsAPI(client: client)
|
statisticsAPI = StatisticsAPI(client: client)
|
||||||
orderAPI = OrderAPI(client: client)
|
orderAPI = OrderAPI(client: client)
|
||||||
|
homeAPI = HomeAPI(client: client)
|
||||||
uploadAPI = UploadAPI(client: client)
|
uploadAPI = UploadAPI(client: client)
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
client.bindAuthTokenProvider {
|
client.bindAuthTokenProvider {
|
||||||
@ -40,6 +42,7 @@ final class NetworkServices {
|
|||||||
profileAPI = ProfileAPI(client: apiClient)
|
profileAPI = ProfileAPI(client: apiClient)
|
||||||
statisticsAPI = StatisticsAPI(client: apiClient)
|
statisticsAPI = StatisticsAPI(client: apiClient)
|
||||||
orderAPI = OrderAPI(client: apiClient)
|
orderAPI = OrderAPI(client: apiClient)
|
||||||
|
homeAPI = HomeAPI(client: apiClient)
|
||||||
uploadAPI = UploadAPI(client: apiClient)
|
uploadAPI = UploadAPI(client: apiClient)
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,13 @@ final class AppStore {
|
|||||||
static let currentScenicId = "key_in_current_scenic_id"
|
static let currentScenicId = "key_in_current_scenic_id"
|
||||||
static let currentScenicName = "key_in_current_scenic_name"
|
static let currentScenicName = "key_in_current_scenic_name"
|
||||||
static let currentStoreId = "key_in_current_store_id"
|
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
|
private let defaults: UserDefaults
|
||||||
@ -123,6 +130,39 @@ final class AppStore {
|
|||||||
set { defaults.set(newValue, forKey: Key.currentStoreId) }
|
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? {
|
var currentAppRole: AppRoleCode? {
|
||||||
AppRoleCode.fromCode(roleCode) ?? AppRoleCode.fromRoleName(roleName)
|
AppRoleCode.fromCode(roleCode) ?? AppRoleCode.fromRoleName(roleName)
|
||||||
@ -183,6 +223,7 @@ final class AppStore {
|
|||||||
|
|
||||||
/// 清除登录态与会话快照。
|
/// 清除登录态与会话快照。
|
||||||
func logout() {
|
func logout() {
|
||||||
|
clearPermissionSnapshot()
|
||||||
token = ""
|
token = ""
|
||||||
userId = ""
|
userId = ""
|
||||||
userName = ""
|
userName = ""
|
||||||
@ -197,4 +238,82 @@ final class AppStore {
|
|||||||
currentScenicName = ""
|
currentScenicName = ""
|
||||||
currentStoreId = 0
|
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
|
self == .photographer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 首页隐藏顶部在线/位置/快捷操作的角色。
|
||||||
|
static let homeMinimalTopRoles: Set<AppRoleCode> = [
|
||||||
|
.storeAdmin,
|
||||||
|
.editor,
|
||||||
|
.scenicCS,
|
||||||
|
.scenicAdmin,
|
||||||
|
.scenicOperation,
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 当前角色是否使用首页精简顶部布局。
|
||||||
|
var usesHomeMinimalTopLayout: Bool {
|
||||||
|
AppRoleCode.homeMinimalTopRoles.contains(self)
|
||||||
|
}
|
||||||
|
|
||||||
/// 从后端 role_code 解析角色。
|
/// 从后端 role_code 解析角色。
|
||||||
static func fromCode(_ code: String?) -> AppRoleCode? {
|
static func fromCode(_ code: String?) -> AppRoleCode? {
|
||||||
let normalized = code?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
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 SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 首页 Tab 根页面。
|
/// 首页 Tab 根页面,按身份与权限展示信息与入口。
|
||||||
final class HomeViewController: BaseViewController {
|
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() {
|
override func setupNavigationBar() {
|
||||||
navigationController?.setNavigationBarHidden(true, animated: false)
|
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
view.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||||
titleLabel.text = "首页"
|
|
||||||
titleLabel.font = .systemFont(ofSize: 24, weight: .medium)
|
contentStack.axis = .vertical
|
||||||
titleLabel.textAlignment = .center
|
contentStack.spacing = 12
|
||||||
titleLabel.textColor = AppColor.text333
|
|
||||||
view.addSubview(titleLabel)
|
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() {
|
override func setupConstraints() {
|
||||||
titleLabel.snp.makeConstraints { make in
|
scenicHeaderView.snp.makeConstraints { make in
|
||||||
make.center.equalToSuperview()
|
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