实现权限驱动的首页 Tab 并对齐 Android

This commit is contained in:
2026-07-06 17:27:54 +08:00
parent e04ad8f8f0
commit d79d3003e3
27 changed files with 2573 additions and 10 deletions

View File

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

View File

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

View File

@ -45,6 +45,20 @@ enum AppRoleCode: String, CaseIterable, Equatable {
self == .photographer
}
/// 线//
static let homeMinimalTopRoles: Set<AppRoleCode> = [
.storeAdmin,
.editor,
.scenicCS,
.scenicAdmin,
.scenicOperation,
]
/// 使
var usesHomeMinimalTopLayout: Bool {
AppRoleCode.homeMinimalTopRoles.contains(self)
}
/// role_code
static func fromCode(_ code: String?) -> AppRoleCode? {
let normalized = code?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""

View 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),
]
)
)
}
}

View 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"
)
}

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

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

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

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

View 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: "")
}
}

View 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?()
}
}

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

View 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)
}
}
}

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

View 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?()
}
}

View File

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

View 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)
}
}

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

View 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?() }
}

View 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?() }
}

View 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?()
}
}

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

View 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?() }
}