diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index 2d642eb..a2cd5ef 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -15,6 +15,7 @@ final class NetworkServices { let profileAPI: ProfileAPI let statisticsAPI: StatisticsAPI let orderAPI: OrderAPI + let homeAPI: HomeAPI let uploadAPI: UploadAPI let ossUploadService: OSSUploadService @@ -25,6 +26,7 @@ final class NetworkServices { profileAPI = ProfileAPI(client: client) statisticsAPI = StatisticsAPI(client: client) orderAPI = OrderAPI(client: client) + homeAPI = HomeAPI(client: client) uploadAPI = UploadAPI(client: client) ossUploadService = OSSUploadService(configService: uploadAPI) client.bindAuthTokenProvider { @@ -40,6 +42,7 @@ final class NetworkServices { profileAPI = ProfileAPI(client: apiClient) statisticsAPI = StatisticsAPI(client: apiClient) orderAPI = OrderAPI(client: apiClient) + homeAPI = HomeAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient) ossUploadService = OSSUploadService(configService: uploadAPI) } diff --git a/suixinkan/DataStore/AppStore.swift b/suixinkan/DataStore/AppStore.swift index c47dc0c..07141df 100644 --- a/suixinkan/DataStore/AppStore.swift +++ b/suixinkan/DataStore/AppStore.swift @@ -26,6 +26,13 @@ final class AppStore { static let currentScenicId = "key_in_current_scenic_id" static let currentScenicName = "key_in_current_scenic_name" static let currentStoreId = "key_in_current_store_id" + static let legacyRoleId = "key_in_role_id" + static let rolePermissionList = "key_role_permission_list" + static let permissionItems = "key_in_permission" + static let roleScenicList = "key_current_role_scenic_list" + static let onlineStatus = "key_online_status" + static let lastLocationReportTime = "key_last_location_report_time" + static let locationReminderMinutes = "key_location_reminder_minutes" } private let defaults: UserDefaults @@ -123,6 +130,39 @@ final class AppStore { set { defaults.set(newValue, forKey: Key.currentStoreId) } } + /// 旧版 role_id,用于权限匹配兜底。 + var legacyRoleId: Int { + get { defaults.integer(forKey: accountScopedKey(Key.legacyRoleId)) } + set { defaults.set(newValue, forKey: accountScopedKey(Key.legacyRoleId)) } + } + + /// 是否在线(位置上报会话)。 + var onlineStatus: Bool { + get { defaults.bool(forKey: accountScopedKey(Key.onlineStatus)) } + set { defaults.set(newValue, forKey: accountScopedKey(Key.onlineStatus)) } + } + + /// 上次位置上报时间戳(毫秒)。 + var lastLocationReportTime: Int64 { + get { Int64(defaults.integer(forKey: accountScopedKey(Key.lastLocationReportTime))) } + set { defaults.set(Int(newValue), forKey: accountScopedKey(Key.lastLocationReportTime)) } + } + + /// 位置超时提前提醒分钟数,0 表示不提醒。 + var locationReminderMinutes: Int { + get { defaults.integer(forKey: accountScopedKey(Key.locationReminderMinutes)) } + set { defaults.set(newValue, forKey: accountScopedKey(Key.locationReminderMinutes)) } + } + + /// 当前账号缓存前缀,对齐 Android `getAccountCachePrefix`。 + var accountCachePrefix: String { + let uid = userId.trimmingCharacters(in: .whitespacesAndNewlines) + let type = accountType.trimmingCharacters(in: .whitespacesAndNewlines) + if uid.isEmpty { return "guest" } + if type.isEmpty { return uid } + return "\(uid)_\(type)" + } + /// 当前解析后的业务角色。 var currentAppRole: AppRoleCode? { AppRoleCode.fromCode(roleCode) ?? AppRoleCode.fromRoleName(roleName) @@ -183,6 +223,7 @@ final class AppStore { /// 清除登录态与会话快照。 func logout() { + clearPermissionSnapshot() token = "" userId = "" userName = "" @@ -197,4 +238,82 @@ final class AppStore { currentScenicName = "" currentStoreId = 0 } + + /// 保存完整 role-permission 列表。 + func saveRolePermissionList(_ list: [RolePermissionResponse]) { + guard let data = try? JSONEncoder().encode(list) else { return } + defaults.set(data, forKey: accountScopedKey(Key.rolePermissionList)) + } + + /// 读取完整 role-permission 列表。 + func rolePermissionList() -> [RolePermissionResponse] { + guard let data = defaults.data(forKey: accountScopedKey(Key.rolePermissionList)), + let list = try? JSONDecoder().decode([RolePermissionResponse].self, from: data) else { + return [] + } + return list + } + + /// 保存当前角色扁平权限。 + func savePermissionItems(_ items: [HomePermissionItem]) { + guard let data = try? JSONEncoder().encode(items) else { return } + defaults.set(data, forKey: accountScopedKey(Key.permissionItems)) + } + + /// 读取当前角色扁平权限。 + func permissionItems() -> [HomePermissionItem] { + guard let data = defaults.data(forKey: accountScopedKey(Key.permissionItems)), + let items = try? JSONDecoder().decode([HomePermissionItem].self, from: data) else { + return [] + } + return items + } + + /// 保存当前角色可选景区列表。 + func saveRoleScenicList(_ scenicList: [ScenicInfo]) { + guard let data = try? JSONEncoder().encode(scenicList) else { return } + defaults.set(data, forKey: accountScopedKey(Key.roleScenicList)) + } + + /// 读取当前角色可选景区列表。 + func roleScenicList() -> [ScenicInfo] { + guard let data = defaults.data(forKey: accountScopedKey(Key.roleScenicList)), + let list = try? JSONDecoder().decode([ScenicInfo].self, from: data) else { + return [] + } + return list + } + + /// 在 role-permission 列表中匹配当前账号角色。 + func matchRolePermissionItem(in list: [RolePermissionResponse]) -> RolePermissionResponse? { + RolePermissionMatcher.match( + in: list, + roleCode: roleCode, + roleName: roleName, + legacyRoleId: legacyRoleId + ) + } + + /// 清除上次位置上报时间。 + func clearLastLocationReportTime() { + defaults.removeObject(forKey: accountScopedKey(Key.lastLocationReportTime)) + } + + /// 清除权限与位置会话快照。 + func clearPermissionSnapshot() { + let prefix = accountScopedKey("") + let keys = [ + Key.rolePermissionList, + Key.permissionItems, + Key.roleScenicList, + Key.onlineStatus, + Key.lastLocationReportTime, + Key.locationReminderMinutes, + ] + keys.forEach { defaults.removeObject(forKey: prefix + $0) } + } + + private func accountScopedKey(_ key: String) -> String { + "\(accountCachePrefix)_\(key)" + } } diff --git a/suixinkan/Features/Auth/Models/AppRoleCode.swift b/suixinkan/Features/Auth/Models/AppRoleCode.swift index e33bbf2..5675b97 100644 --- a/suixinkan/Features/Auth/Models/AppRoleCode.swift +++ b/suixinkan/Features/Auth/Models/AppRoleCode.swift @@ -45,6 +45,20 @@ enum AppRoleCode: String, CaseIterable, Equatable { self == .photographer } + /// 首页隐藏顶部在线/位置/快捷操作的角色。 + static let homeMinimalTopRoles: Set = [ + .storeAdmin, + .editor, + .scenicCS, + .scenicAdmin, + .scenicOperation, + ] + + /// 当前角色是否使用首页精简顶部布局。 + var usesHomeMinimalTopLayout: Bool { + AppRoleCode.homeMinimalTopRoles.contains(self) + } + /// 从后端 role_code 解析角色。 static func fromCode(_ code: String?) -> AppRoleCode? { let normalized = code?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" diff --git a/suixinkan/Features/Home/API/HomeAPI.swift b/suixinkan/Features/Home/API/HomeAPI.swift new file mode 100644 index 0000000..eac01aa --- /dev/null +++ b/suixinkan/Features/Home/API/HomeAPI.swift @@ -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), + ] + ) + ) + } +} diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift new file mode 100644 index 0000000..d5b6a21 --- /dev/null +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -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" + ) +} diff --git a/suixinkan/Features/Home/Models/LocationModels.swift b/suixinkan/Features/Home/Models/LocationModels.swift new file mode 100644 index 0000000..07ebbae --- /dev/null +++ b/suixinkan/Features/Home/Models/LocationModels.swift @@ -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 + } +} diff --git a/suixinkan/Features/Home/Models/RolePermissionModels.swift b/suixinkan/Features/Home/Models/RolePermissionModels.swift new file mode 100644 index 0000000..33b0ebb --- /dev/null +++ b/suixinkan/Features/Home/Models/RolePermissionModels.swift @@ -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 + } +} diff --git a/suixinkan/Features/Home/Models/StoreModels.swift b/suixinkan/Features/Home/Models/StoreModels.swift new file mode 100644 index 0000000..a799d80 --- /dev/null +++ b/suixinkan/Features/Home/Models/StoreModels.swift @@ -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`。 +struct StoreListResponse: Codable, Sendable { + let total: Int + let list: [StoreItem] + + init(total: Int = 0, list: [StoreItem] = []) { + self.total = total + self.list = list + } +} diff --git a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift new file mode 100644 index 0000000..cfb739b --- /dev/null +++ b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift @@ -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" + } +} diff --git a/suixinkan/Features/Home/Services/HomeLocationProvider.swift b/suixinkan/Features/Home/Services/HomeLocationProvider.swift new file mode 100644 index 0000000..69449d2 --- /dev/null +++ b/suixinkan/Features/Home/Services/HomeLocationProvider.swift @@ -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? + + 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: "") + } +} diff --git a/suixinkan/Features/Home/Services/HomeLocationStateStore.swift b/suixinkan/Features/Home/Services/HomeLocationStateStore.swift new file mode 100644 index 0000000..afcda6d --- /dev/null +++ b/suixinkan/Features/Home/Services/HomeLocationStateStore.swift @@ -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? + 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?() + } +} diff --git a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift new file mode 100644 index 0000000..eb0bfd6 --- /dev/null +++ b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift @@ -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 } + } +} diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift new file mode 100644 index 0000000..bad768d --- /dev/null +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -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) + } + } +} diff --git a/suixinkan/Features/Home/Services/RolePermissionMatcher.swift b/suixinkan/Features/Home/Services/RolePermissionMatcher.swift new file mode 100644 index 0000000..7b643f0 --- /dev/null +++ b/suixinkan/Features/Home/Services/RolePermissionMatcher.swift @@ -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 + } +} diff --git a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift new file mode 100644 index 0000000..f9a6cfd --- /dev/null +++ b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift @@ -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?() + } +} diff --git a/suixinkan/UI/Home/HomeViewController.swift b/suixinkan/UI/Home/HomeViewController.swift index 5f3929f..cbf3526 100644 --- a/suixinkan/UI/Home/HomeViewController.swift +++ b/suixinkan/UI/Home/HomeViewController.swift @@ -6,27 +6,359 @@ import SnapKit import UIKit -/// 首页 Tab 根页面。 +/// 首页 Tab 根页面,按身份与权限展示信息与入口。 final class HomeViewController: BaseViewController { - private let titleLabel = UILabel() + private let viewModel = HomeViewModel() + private let homeAPI = NetworkServices.shared.homeAPI + + private let scenicHeaderView = HomeScenicHeaderView() + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let workStatusCardView = HomeWorkStatusCardView() + private let locationReportCardView = HomeLocationReportCardView() + private let quickActionsView = HomeQuickActionsView() + private let storeCardView = HomeStoreCardView() + private let commonAppsGridView = HomeCommonAppsGridView() + + private var hasInitialized = false + private var countdownTimer: Timer? + private var activeDialog: HomeDialogKind? override func setupNavigationBar() { navigationController?.setNavigationBarHidden(true, animated: false) } override func setupUI() { - view.backgroundColor = UIColor(hex: 0xF7FAFF) - titleLabel.text = "首页" - titleLabel.font = .systemFont(ofSize: 24, weight: .medium) - titleLabel.textAlignment = .center - titleLabel.textColor = AppColor.text333 - view.addSubview(titleLabel) + view.backgroundColor = UIColor(hex: 0xF5F5F5) + + contentStack.axis = .vertical + contentStack.spacing = 12 + + scrollView.showsVerticalScrollIndicator = false + view.addSubview(scenicHeaderView) + view.addSubview(scrollView) + scrollView.addSubview(contentStack) + + contentStack.addArrangedSubview(workStatusCardView) + contentStack.addArrangedSubview(locationReportCardView) + contentStack.addArrangedSubview(quickActionsView) + contentStack.addArrangedSubview(storeCardView) + contentStack.addArrangedSubview(commonAppsGridView) } override func setupConstraints() { - titleLabel.snp.makeConstraints { make in - make.center.equalToSuperview() + scenicHeaderView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(52) + } + scrollView.snp.makeConstraints { make in + make.top.equalTo(scenicHeaderView.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + make.width.equalTo(scrollView.snp.width).offset(-32) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + + scenicHeaderView.onTap = { [weak self] in self?.presentScenicSelection() } + workStatusCardView.onOnlineTap = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() } + workStatusCardView.onReminderTap = { [weak self] in self?.presentReminderPicker() } + locationReportCardView.onReportTap = { [weak self] in + Task { await self?.viewModel.manualReportLocation(api: self?.homeAPI ?? NetworkServices.shared.homeAPI) } + } + quickActionsView.onCollectPayment = { [weak self] in self?.showToast("功能开发中") } + quickActionsView.onSubmitTask = { [weak self] in self?.showToast("功能开发中") } + quickActionsView.onToggleOnline = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() } + commonAppsGridView.onMenuSelected = { [weak self] menu in + guard let self else { return } + HomeRouteHandler.open(uri: menu.uri, from: self, storeItem: self.viewModel.storeItem) + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAccountDidSwitch), + name: NotificationName.accountDidSwitch, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleScenicDidChange), + name: NotificationName.scenicDidChange, + object: nil + ) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if !hasInitialized { + hasInitialized = true + Task { await initializeHome() } + } else { + Task { await viewModel.reloadIfNeeded(api: homeAPI) } + } + startCountdownTimer() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + countdownTimer?.invalidate() + countdownTimer = nil + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func initializeHome() async { + showLoading() + defer { hideLoading() } + await viewModel.initialize(api: homeAPI) + applyViewModel() + await evaluateDialogsWithDelay() + await viewModel.refreshLocationInfo() + applyViewModel() + } + + private func evaluateDialogsWithDelay() async { + try? await Task.sleep(nanoseconds: 100_000_000) + await viewModel.evaluateDialogs(api: homeAPI) + applyViewModel() + presentDialogsIfNeeded() + } + + @MainActor + private func applyViewModel() { + scenicHeaderView.apply(scenicName: viewModel.currentScenicName) + let showWorkBlock = !viewModel.isMinimalTopRole + workStatusCardView.isHidden = !showWorkBlock + locationReportCardView.isHidden = !showWorkBlock + quickActionsView.isHidden = !showWorkBlock + + if showWorkBlock { + workStatusCardView.apply( + isOnline: viewModel.isOnline, + countdownText: viewModel.countdownDisplayText, + reminderMinutes: viewModel.reminderMinutes + ) + locationReportCardView.apply( + address: viewModel.locationInfoText, + isReporting: viewModel.isLocationReporting + ) + quickActionsView.apply(isOnline: viewModel.isOnline) + } + + let showStore = viewModel.currentAppRole == .storeAdmin && viewModel.storeItem != nil + storeCardView.isHidden = !showStore + if showStore, let store = viewModel.storeItem { + storeCardView.apply(store: store) + } + + commonAppsGridView.apply(menus: viewModel.commonMenus) + presentDialogsIfNeeded() + } + + private func presentDialogsIfNeeded() { + if viewModel.showPermissionDialog { + presentDialog(.permission) + return + } + if viewModel.showScenicDialog { + presentDialog(.scenic) + return + } + if viewModel.showOnlineStatusDialog { + presentDialog(.onlineStatus) + return + } + if viewModel.showLocationTimeoutDialog { + presentDialog(.locationTimeout) + return + } + if viewModel.showLocationReportSuccessDialog { + presentDialog(.locationSuccess) + return + } + activeDialog = nil + } + + private enum HomeDialogKind { + case permission + case scenic + case onlineStatus + case locationTimeout + case locationSuccess + } + + private func presentDialog(_ kind: HomeDialogKind) { + guard activeDialog != kind, presentedViewController == nil else { return } + activeDialog = kind + switch kind { + case .permission: presentPermissionDialog() + case .scenic: presentScenicDialog() + case .onlineStatus: presentOnlineStatusDialog() + case .locationTimeout: presentLocationTimeoutDialog() + case .locationSuccess: presentLocationSuccessDialog() + } + } + + private func presentPermissionDialog() { + let alert = UIAlertController( + title: "权限提示", + message: "您还没有权限,请先申请权限", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in + self?.viewModel.hidePermissionDialog() + self?.activeDialog = nil + }) + alert.addAction(UIAlertAction(title: "去申请", style: .default) { [weak self] _ in + self?.showToast("功能开发中") + self?.activeDialog = nil + }) + present(alert, animated: true) + } + + private func presentScenicDialog() { + let alert = UIAlertController( + title: "请选择景区", + message: "使用首页功能前需要先选择当前景区", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { [weak self] _ in + self?.viewModel.hideScenicDialog() + self?.activeDialog = nil + }) + alert.addAction(UIAlertAction(title: "去选择", style: .default) { [weak self] _ in + self?.viewModel.hideScenicDialog() + self?.activeDialog = nil + self?.presentScenicSelection() + }) + present(alert, animated: true) + } + + private func presentOnlineStatusDialog() { + let goingOnline = !viewModel.isOnline + let alert = UIAlertController( + title: goingOnline ? "切换在线" : "切换离线", + message: goingOnline ? "确认切换到在线状态并上报位置?" : "确认切换到离线状态?", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in + self?.viewModel.hideOnlineStatusSwitchDialog() + self?.activeDialog = nil + }) + alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in + guard let self else { return } + self.activeDialog = nil + Task { await self.viewModel.switchOnlineStatus(api: self.homeAPI) } + }) + present(alert, animated: true) + } + + private func presentLocationTimeoutDialog() { + let alert = UIAlertController( + title: "位置上报提醒", + message: "您的位置上报即将超时,请立即上报", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { [weak self] _ in + self?.viewModel.hideLocationTimeoutDialog() + self?.activeDialog = nil + }) + alert.addAction(UIAlertAction(title: "立即上报", style: .default) { [weak self] _ in + guard let self else { return } + self.activeDialog = nil + Task { await self.viewModel.confirmLocationTimeoutReport(api: self.homeAPI) } + }) + present(alert, animated: true) + } + + private func presentLocationSuccessDialog() { + let alert = UIAlertController( + title: "上报成功", + message: "上报时间:\(viewModel.reportTimeText)", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in + self?.viewModel.hideLocationReportSuccessDialog() + self?.activeDialog = nil + }) + present(alert, animated: true) + } + + private func presentReminderPicker() { + let alert = UIAlertController(title: "提前提醒", message: "选择位置超时提前提醒时间", preferredStyle: .actionSheet) + [0, 5, 10, 15, 30].forEach { minutes in + let title = minutes == 0 ? "不提醒" : "提前\(minutes)分钟" + alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in + self?.viewModel.updateReminderMinutes(minutes) + }) + } + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + present(alert, animated: true) + } + + private func presentScenicSelection() { + let scenicList = AppStore.shared.roleScenicList() + guard !scenicList.isEmpty else { + showToast("暂无可选景区") + return + } + let controller = ScenicSelectionViewController(scenicList: scenicList) + controller.onSelected = { [weak self] in + guard let self else { return } + Task { + await self.viewModel.reloadIfNeeded(api: self.homeAPI) + await self.viewModel.loadStoreListIfNeeded(api: self.homeAPI) + self.applyViewModel() + await self.evaluateDialogsWithDelay() + } + } + let nav = UINavigationController(rootViewController: controller) + nav.modalPresentationStyle = .pageSheet + present(nav, animated: true) + } + + private func startCountdownTimer() { + countdownTimer?.invalidate() + countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + guard let self else { return } + self.viewModel.triggerLocationTimeoutIfNeeded() + if !self.viewModel.isMinimalTopRole { + self.workStatusCardView.apply( + isOnline: self.viewModel.isOnline, + countdownText: self.viewModel.countdownDisplayText, + reminderMinutes: self.viewModel.reminderMinutes + ) + } + } + } + + @objc private func handleAccountDidSwitch() { + viewModel.markNeedsPermissionReload() + Task { + await viewModel.reloadIfNeeded(api: homeAPI) + applyViewModel() + await evaluateDialogsWithDelay() + } + } + + @objc private func handleScenicDidChange() { + viewModel.refreshLocalDisplayState() + Task { + await viewModel.loadStoreListIfNeeded(api: homeAPI) + applyViewModel() } } } diff --git a/suixinkan/UI/Home/ScenicSelectionViewController.swift b/suixinkan/UI/Home/ScenicSelectionViewController.swift new file mode 100644 index 0000000..9c133db --- /dev/null +++ b/suixinkan/UI/Home/ScenicSelectionViewController.swift @@ -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) + } +} diff --git a/suixinkan/UI/Home/Views/HomeCommonAppsGridView.swift b/suixinkan/UI/Home/Views/HomeCommonAppsGridView.swift new file mode 100644 index 0000000..5630116 --- /dev/null +++ b/suixinkan/UI/Home/Views/HomeCommonAppsGridView.swift @@ -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(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() + 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 + } +} diff --git a/suixinkan/UI/Home/Views/HomeLocationReportCardView.swift b/suixinkan/UI/Home/Views/HomeLocationReportCardView.swift new file mode 100644 index 0000000..57693f4 --- /dev/null +++ b/suixinkan/UI/Home/Views/HomeLocationReportCardView.swift @@ -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?() } +} diff --git a/suixinkan/UI/Home/Views/HomeQuickActionsView.swift b/suixinkan/UI/Home/Views/HomeQuickActionsView.swift new file mode 100644 index 0000000..8f2858d --- /dev/null +++ b/suixinkan/UI/Home/Views/HomeQuickActionsView.swift @@ -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?() } +} diff --git a/suixinkan/UI/Home/Views/HomeScenicHeaderView.swift b/suixinkan/UI/Home/Views/HomeScenicHeaderView.swift new file mode 100644 index 0000000..7fab09f --- /dev/null +++ b/suixinkan/UI/Home/Views/HomeScenicHeaderView.swift @@ -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?() + } +} diff --git a/suixinkan/UI/Home/Views/HomeStoreCardView.swift b/suixinkan/UI/Home/Views/HomeStoreCardView.swift new file mode 100644 index 0000000..a756b79 --- /dev/null +++ b/suixinkan/UI/Home/Views/HomeStoreCardView.swift @@ -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 + } +} diff --git a/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift b/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift new file mode 100644 index 0000000..d6bc083 --- /dev/null +++ b/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift @@ -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?() } +} diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift new file mode 100644 index 0000000..c0ac4c0 --- /dev/null +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -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"]) + } +} diff --git a/suixinkanTests/HomeLocationStateStoreTests.swift b/suixinkanTests/HomeLocationStateStoreTests.swift new file mode 100644 index 0000000..881d11a --- /dev/null +++ b/suixinkanTests/HomeLocationStateStoreTests.swift @@ -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()) + } +} diff --git a/suixinkanTests/HomeViewModelTests.swift b/suixinkanTests/HomeViewModelTests.swift new file mode 100644 index 0000000..b362d40 --- /dev/null +++ b/suixinkanTests/HomeViewModelTests.swift @@ -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) + } +} diff --git a/suixinkanTests/RolePermissionMatcherTests.swift b/suixinkanTests/RolePermissionMatcherTests.swift new file mode 100644 index 0000000..64daa45 --- /dev/null +++ b/suixinkanTests/RolePermissionMatcherTests.swift @@ -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, "剪辑师") + } +}