Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,38 @@
# Home 模块业务逻辑
## 模块职责
Home 模块负责登录后的首页工作台,包括当前景区展示、工作状态、位置上报入口、快捷操作、常用应用和全部功能入口。
首页菜单来自 `PermissionContext` 中的角色权限。当前模块只同步首页壳和入口路由,入口背后的业务子模块后续逐个迁移。
## 菜单生成
`HomeViewModel` 从当前角色的权限树递归提取菜单:
- 当前角色 ID 存在但找不到时清空菜单。
- 当前角色 ID 为空时使用第一个角色。
- URI 按旧工程顺序排序。
- 同义 URI 按 `HomeMenuRouter.menuAliasKey` 去重。
## 常用应用
`HomeCommonMenuStore` 使用 UserDefaults 保存常用应用 URI
- 首次无配置时写入默认常用应用。
- 读取时按当前角色权限过滤不可用 URI。
- 添加和移除时按同义 URI 去重。
## 路由规则
`HomeMenuRouter` 将权限 URI 分为四类:
- `tab`:切换到订单或数据 Tab。
- `destination`:进入已接入的本地页面,如个人信息、景区选择、权限申请、全部功能。
- `unsupported`:已知 iOS 不支持的入口,进入占位说明。
- `placeholder`:未知或未迁移入口,记录诊断并进入占位说明。
`HomeView` 不创建自己的 `NavigationStack`,而是使用 Main Tab 注入的 `RouterPath` 进行页面跳转。
## 后续迁移
目前 `payment_collection``payment_qr``payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection``permission_apply``permission_apply_status``scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management``task_management_editor``task_create` 已由 `Features/Tasks` 接管,`cloud_management``cloud_storage_transit``asset_management``material_upload``album_list``album_trailer``sample_management``sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report``location_report_history` 已由 `Features/LocationReport` 接管,`pm``project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation``photographer_invite``invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail``deposit_order``deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement``scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue``queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management``live_album` 已由 `Features/Live` 接管,`operating-area` 已由 `Features/OperatingArea` 接管,`pilot_cert` 已由 `Features/PilotCertification` 接管。
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。

View File

@ -0,0 +1,85 @@
//
// HomeIconCatalog.swift
// suixinkan
//
import Foundation
/// URI
enum HomeIconCatalog {
/// URI SF Symbols
static func iconName(for uri: String) -> String {
switch uri {
case "space_settings":
"person.crop.square.fill"
case "album_list":
"photo.stack"
case "album_trailer":
"square.stack.3d.up.fill"
case "wallet":
"creditcard.fill"
case "cloud_management":
"icloud.fill"
case "cloud_storage_transit":
"arrow.left.arrow.right.circle.fill"
case "asset_management", "/scenic-order-manage":
"photo.on.rectangle.angled"
case "material_upload":
"square.and.arrow.up"
case "task_management", "task_management_editor":
"checklist"
case "schedule_management":
"calendar"
case "system_settings":
"gearshape.fill"
case "message_center":
"bell.fill"
case "checkin_points":
"mappin.and.ellipse"
case "sample_management":
"point.3.connected.trianglepath.dotted"
case "sample_upload":
"square.and.arrow.up.on.square"
case "live_stream_management":
"dot.radiowaves.left.and.right"
case "verification_order":
"checkmark.seal.fill"
case "live_album":
"play.rectangle.on.rectangle.fill"
case "pm", "pm_manager", "project_edit":
"circle.grid.2x2.fill"
case "location_report", "location_report_history":
"mappin.circle.fill"
case "registration_invitation", "photographer_invite":
"envelope.open.fill"
case "store":
"storefront.fill"
case "fly", "pilot_cert", "pilot_controller":
"paperplane.fill"
case "/scenic-queue", "queue_management":
"person.3.fill"
case "operating-area":
"map.fill"
case "scenicselection":
"location.magnifyingglass"
case "scenicapplication":
"doc.badge.plus"
case "permission_apply", "permission_apply_status":
"person.badge.key"
case "scenic_settlement", "scenic_settlement_review":
"checklist"
case "payment_collection", "payment_qr", "payment_code":
"qrcode"
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
"doc.text.magnifyingglass"
case "withdrawal_audit":
"banknote.fill"
case "invite_record":
"list.bullet.rectangle.fill"
case "more_functions":
"ellipsis"
default:
"square.grid.2x2.fill"
}
}
}

View File

@ -0,0 +1,104 @@
//
// HomeMenuItem.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// URI
struct HomeMenuItem: Equatable, Identifiable {
let title: String
let uri: String
let iconSrc: String?
var id: String {
uri
}
}
///
enum HomeRoute: Hashable {
case profileSpace
case scenicSelection
case permissionApply
case permissionApplyStatus
case scenicApplication
case moreFunctions
case settings
case paymentCollection
case wallet
case taskManagement
case taskCreate
case taskDetail(id: Int, summary: PhotographerTaskItem?)
case projectManagement
case pmProjectManagement
case projectDetail(id: Int, storeMode: Bool)
case projectEditor(id: Int?, storeMode: Bool)
case scheduleManagement
case scheduleAdd
case photographerInvite
case inviteRecord
case cloudStorage
case cloudStorageTransit
case materialLibrary
case materialUpload
case sampleLibrary
case sampleUpload
case albumList
case albumTrailer
case punchPointList
case punchPointDetail(id: Int, summary: PunchPointItem?)
case punchPointEditor(id: Int?)
case punchPointQR(id: Int, title: String, qrURL: String)
case locationReport
case locationReportHistory
case depositOrders
case withdrawalAudit
case scenicSettlement
case scenicSettlementReview
case messageCenter
case queueManagement
case liveManagement
case liveAlbum
case operatingArea
case pilotCertification
case modulePlaceholder(uri: String, title: String)
}
/// URI Tab
enum HomeMenuResolvedRoute: Equatable {
case tab(AppTab)
case orders(OrdersEntry)
case destination(HomeRoute)
case unsupported(uri: String, title: String, reason: String)
case placeholder(uri: String, title: String)
}
/// iOS URI
struct UnknownHomeRouteRecord: Codable, Equatable {
let uri: String
let title: String
let firstSeenAt: Date
let lastSeenAt: Date
let count: Int
}
///
struct HomePermissionRouteAuditEntry: Equatable {
let uri: String
let title: String
let route: HomeMenuResolvedRoute
}
///
struct HomePermissionRouteAudit: Equatable {
let routable: [HomePermissionRouteAuditEntry]
let unsupported: [HomePermissionRouteAuditEntry]
let unknown: [HomePermissionRouteAuditEntry]
var hasUnknownRoutes: Bool {
!unknown.isEmpty
}
}

View File

@ -0,0 +1,503 @@
//
// HomeMenuRouter.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import os
/// URI iOS
enum HomeMenuRouter {
private static let titleMap: [String: String] = [
"basic_info": "基本信息",
"space_settings": "空间设置",
"album_list": "相册管理",
"album_trailer": "相册预览上传",
"photographer_stats": "数据统计",
"photographer_orders": "订单管理",
"/scenic-order-manage": "景区订单",
"verification_order": "核销订单",
"wallet": "我的钱包",
"cloud_management": "相册云盘",
"cloud_storage_transit": "传输管理",
"asset_management": "素材管理",
"material_upload": "上传素材",
"task_management": "任务管理",
"task_management_editor": "任务管理",
"task_create": "发布任务",
"schedule_management": "日程管理",
"system_settings": "设置中心",
"message_center": "消息中心",
"checkin_points": "打卡点管理",
"sample_management": "样片管理",
"sample_upload": "上传样片",
"live_stream_management": "直播管理",
"live_album": "直播相册",
"scenicselection": "景区选择",
"scenicapplication": "景区申请",
"permission_apply": "权限申请",
"permission_apply_status": "权限申请状态",
"scenic_settlement": "景区结算",
"scenic_settlement_review": "结算审核",
"pm": "项目管理",
"pm_manager": "店铺项目管理",
"project_edit": "项目管理",
"location_report": "位置上报",
"registration_invitation": "注册邀请",
"store": "店铺管理",
"fly": "飞行管理",
"pilot_cert": "飞手认证",
"pilot_controller": "飞控",
"/scenic-queue": "排队管理",
"queue_management": "排队管理",
"operating-area": "运营区域",
"payment_collection": "立即收款",
"payment_qr": "收款码",
"payment_code": "收款码",
"deposit_order_detail": "押金订单详情",
"deposit_order": "押金订单详情",
"deposit_order_shooting_info": "押金拍摄信息",
"withdrawal_audit": "提现审核",
"location_report_history": "定位上报历史",
"photographer_invite": "邀请摄影师",
"invite_record": "邀请记录",
"more_functions": "更多功能"
]
/// URI
static func resolve(uri: String, title: String) -> HomeMenuResolvedRoute {
switch uri {
case "photographer_orders", "/scenic-order-manage":
return .orders(.storeOrders)
case "verification_order":
return .orders(.verificationOrders)
case "photographer_stats":
return .tab(.statistics)
case "space_settings", "basic_info":
return .destination(.profileSpace)
case "system_settings":
return .destination(.settings)
case "scenicselection":
return .destination(.scenicSelection)
case "permission_apply":
return .destination(.permissionApply)
case "permission_apply_status":
return .destination(.permissionApplyStatus)
case "scenicapplication":
return .destination(.scenicApplication)
case "wallet":
return .destination(.wallet)
case "payment_collection", "payment_qr", "payment_code":
return .destination(.paymentCollection)
case "task_management", "task_management_editor":
return .destination(.taskManagement)
case "task_create":
return .destination(.taskCreate)
case "schedule_management":
return .destination(.scheduleManagement)
case "pm", "project_edit":
return .destination(.projectManagement)
case "pm_manager":
return .destination(.pmProjectManagement)
case "registration_invitation", "photographer_invite":
return .destination(.photographerInvite)
case "invite_record":
return .destination(.inviteRecord)
case "cloud_management":
return .destination(.cloudStorage)
case "cloud_storage_transit":
return .destination(.cloudStorageTransit)
case "asset_management":
return .destination(.materialLibrary)
case "material_upload":
return .destination(.materialUpload)
case "sample_management":
return .destination(.sampleLibrary)
case "sample_upload":
return .destination(.sampleUpload)
case "album_list":
return .destination(.albumList)
case "album_trailer":
return .destination(.albumTrailer)
case "checkin_points":
return .destination(.punchPointList)
case "location_report":
return .destination(.locationReport)
case "location_report_history":
return .destination(.locationReportHistory)
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
return .destination(.depositOrders)
case "withdrawal_audit":
return .destination(.withdrawalAudit)
case "scenic_settlement":
return .destination(.scenicSettlement)
case "scenic_settlement_review":
return .destination(.scenicSettlementReview)
case "message_center":
return .destination(.messageCenter)
case "/scenic-queue", "queue_management":
return .destination(.queueManagement)
case "live_stream_management":
return .destination(.liveManagement)
case "live_album":
return .destination(.liveAlbum)
case "operating-area":
return .destination(.operatingArea)
case "pilot_cert":
return .destination(.pilotCertification)
case "store", "more_functions":
return .destination(.moreFunctions)
case "fly", "pilot_controller":
return .unsupported(
uri: uri,
title: title.isEmpty ? self.title(for: uri) : title,
reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。"
)
default:
return .placeholder(uri: uri, title: title.isEmpty ? self.title(for: uri) : title)
}
}
/// URI
static func title(for uri: String) -> String {
titleMap[uri] ?? uri
}
#if DEBUG
/// 使便
static func debugAllMenuItems() -> [HomeMenuItem] {
let preferredOrder = [
"space_settings",
"basic_info",
"album_list",
"album_trailer",
"wallet",
"payment_collection",
"payment_qr",
"payment_code",
"cloud_management",
"cloud_storage_transit",
"asset_management",
"material_upload",
"task_management",
"task_management_editor",
"task_create",
"schedule_management",
"system_settings",
"message_center",
"checkin_points",
"sample_management",
"sample_upload",
"live_stream_management",
"live_album",
"scenicselection",
"scenicapplication",
"permission_apply",
"permission_apply_status",
"scenic_settlement",
"scenic_settlement_review",
"verification_order",
"photographer_orders",
"/scenic-order-manage",
"photographer_stats",
"pm",
"pm_manager",
"project_edit",
"location_report",
"registration_invitation",
"photographer_invite",
"invite_record",
"store",
"fly",
"pilot_cert",
"pilot_controller",
"/scenic-queue",
"queue_management",
"operating-area",
"deposit_order_detail",
"deposit_order",
"deposit_order_shooting_info",
"withdrawal_audit",
"location_report_history",
"more_functions"
]
let orderedURIs = preferredOrder + titleMap.keys.sorted().filter { !preferredOrder.contains($0) }
return orderedURIs.map { uri in
HomeMenuItem(title: title(for: uri), uri: uri, iconSrc: nil)
}
}
#endif
/// URI
static func canonicalURI(for uri: String, availableURIs: Set<String>) -> String {
if availableURIs.contains(uri) {
return uri
}
switch uri {
case "task_management":
return availableURIs.contains("task_management_editor") ? "task_management_editor" : uri
case "task_management_editor":
return availableURIs.contains("task_management") ? "task_management" : uri
case "registration_invitation":
return availableURIs.contains("photographer_invite") ? "photographer_invite" : uri
case "photographer_invite":
return availableURIs.contains("registration_invitation") ? "registration_invitation" : uri
case "pm":
return availableURIs.contains("project_edit") ? "project_edit" : uri
case "project_edit":
return availableURIs.contains("pm") ? "pm" : uri
case "payment_code":
return availableURIs.contains("payment_qr") ? "payment_qr" : uri
default:
return uri
}
}
/// URI
static func menuAliasKey(for uri: String) -> String {
switch uri {
case "task_management", "task_management_editor":
return "task_management"
case "registration_invitation", "photographer_invite":
return "registration_invitation"
case "pm", "project_edit":
return "pm"
case "payment_collection", "payment_qr", "payment_code":
return "payment_collection"
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
return "deposit_order"
case "photographer_orders", "/scenic-order-manage":
return "photographer_orders"
case "/scenic-queue", "queue_management":
return "queue_management"
default:
return uri
}
}
///
static func displayTitle(for uri: String, fallback: String) -> String {
switch uri {
case "registration_invitation", "photographer_invite":
return "注册邀请"
case "location_report":
return "位置上报"
case "pm", "project_edit":
return "项目管理"
case "pm_manager":
return "店铺项目"
case "space_settings":
return "空间设置"
case "task_management", "task_management_editor":
return "任务管理"
default:
return fallback
}
}
}
/// URI
enum HomePermissionRouteAuditor {
/// URI
static func audit(permissions: [RolePermissionResponse], currentRoleId: Int?) -> HomePermissionRouteAudit {
let source: RolePermissionResponse?
if let currentRoleId {
source = permissions.first { $0.role.id == currentRoleId }
} else {
source = permissions.first
}
let entries = uniquePermissionItems(from: source?.role.permission ?? []).map { item in
let title = item.name.isEmpty ? HomeMenuRouter.title(for: item.uri) : item.name
return HomePermissionRouteAuditEntry(
uri: item.uri,
title: title,
route: HomeMenuRouter.resolve(uri: item.uri, title: title)
)
}
return HomePermissionRouteAudit(
routable: entries.filter { entry in
switch entry.route {
case .tab, .orders, .destination:
return true
case .unsupported, .placeholder:
return false
}
},
unsupported: entries.filter { entry in
if case .unsupported = entry.route { return true }
return false
},
unknown: entries.filter { entry in
if case .placeholder = entry.route { return true }
return false
}
)
}
/// Markdown
static func markdownReport(audit: HomePermissionRouteAudit, generatedAt: Date = Date()) -> String {
var lines = [
"# Home Permission Route Audit",
"",
"Generated: \(reportDateFormatter.string(from: generatedAt))",
"",
"Routable: \(audit.routable.count)",
"Unsupported: \(audit.unsupported.count)",
"Unknown: \(audit.unknown.count)",
""
]
if audit.unknown.isEmpty {
lines.append("No unknown permission routes.")
} else {
lines.append("## Unknown Routes")
lines.append("")
lines.append("| URI | Title |")
lines.append("| --- | --- |")
lines.append(contentsOf: audit.unknown.map { "| \(escape($0.uri)) | \(escape($0.title)) |" })
lines.append("")
lines.append("Action: map these URIs in `HomeMenuRouter`, confirm unsupported scope, or remove them before release.")
}
if !audit.unsupported.isEmpty {
lines.append("")
lines.append("## Known Unsupported Routes")
lines.append("")
lines.append("| URI | Title | Reason |")
lines.append("| --- | --- | --- |")
lines.append(contentsOf: audit.unsupported.map { entry in
let reason: String
if case let .unsupported(_, _, value) = entry.route {
reason = value
} else {
reason = ""
}
return "| \(escape(entry.uri)) | \(escape(entry.title)) | \(escape(reason)) |"
})
}
return lines.joined(separator: "\n")
}
private static var reportDateFormatter: ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}
/// URI URI
private static func uniquePermissionItems(from items: [PermissionItem]) -> [PermissionItem] {
var seen = Set<String>()
return flatten(items).filter { item in
guard !item.uri.isEmpty else { return false }
return seen.insert(HomeMenuRouter.menuAliasKey(for: item.uri)).inserted
}
}
///
private static func flatten(_ items: [PermissionItem]) -> [PermissionItem] {
items.flatMap { item in
[item] + flatten(item.children)
}
}
/// Markdown
private static func escape(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "|", with: "\\|")
.replacingOccurrences(of: "\n", with: " ")
}
}
/// URI
enum HomeRouteDiagnostics {
private static let logger = Logger(subsystem: "com.yuanzhixiang.suixinkan", category: "HomeRouting")
private static let defaultsKey = "home.routing.unknown.records"
private static let dateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
/// URI
static func recordUnknown(uri: String, title: String) {
logger.warning("Unknown home menu uri: \(uri, privacy: .public), title: \(title, privacy: .public)")
var records = unknownRoutes()
let now = Date()
if let index = records.firstIndex(where: { $0.uri == uri }) {
let current = records[index]
records[index] = UnknownHomeRouteRecord(
uri: current.uri,
title: title.isEmpty ? current.title : title,
firstSeenAt: current.firstSeenAt,
lastSeenAt: now,
count: current.count + 1
)
} else {
records.append(UnknownHomeRouteRecord(uri: uri, title: title, firstSeenAt: now, lastSeenAt: now, count: 1))
}
save(records)
}
///
static func unknownRoutes() -> [UnknownHomeRouteRecord] {
guard let data = UserDefaults.standard.data(forKey: defaultsKey),
let records = try? JSONDecoder().decode([UnknownHomeRouteRecord].self, from: data)
else { return [] }
return records
}
/// Markdown
static func unknownRouteReport(generatedAt: Date = Date()) -> String {
let records = unknownRoutes().sorted { lhs, rhs in
if lhs.count != rhs.count {
return lhs.count > rhs.count
}
return lhs.lastSeenAt > rhs.lastSeenAt
}
var lines = [
"# Home Route Diagnostics",
"",
"Generated: \(dateFormatter.string(from: generatedAt))",
""
]
guard !records.isEmpty else {
lines.append("No unknown home routes recorded.")
return lines.joined(separator: "\n")
}
lines.append("| URI | Title | Count | First Seen | Last Seen |")
lines.append("| --- | --- | ---: | --- | --- |")
lines.append(contentsOf: records.map { record in
"| \(escape(record.uri)) | \(escape(record.title)) | \(record.count) | \(dateFormatter.string(from: record.firstSeenAt)) | \(dateFormatter.string(from: record.lastSeenAt)) |"
})
lines.append("")
lines.append("Action: each URI above must be mapped in `HomeMenuRouter`, confirmed as unsupported scope, or explicitly accepted before release.")
return lines.joined(separator: "\n")
}
///
static func resetUnknownRoutes() {
UserDefaults.standard.removeObject(forKey: defaultsKey)
}
///
private static func save(_ records: [UnknownHomeRouteRecord]) {
guard let data = try? JSONEncoder().encode(records) else { return }
UserDefaults.standard.set(data, forKey: defaultsKey)
}
/// Markdown
private static func escape(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "|", with: "\\|")
.replacingOccurrences(of: "\n", with: " ")
}
}

View File

@ -0,0 +1,91 @@
//
// HomeCommonMenuStore.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// URI
struct HomeCommonMenuStore {
static let defaultStorageKey = "home.common.menu.uris"
static let defaultBaselineKey = "home.common.menu.android.baseline.v2"
private let defaults: UserDefaults
private let storageKey: String
private let baselineKey: String
/// UserDefaults
init(
defaults: UserDefaults = .standard,
storageKey: String = Self.defaultStorageKey,
baselineKey: String = Self.defaultBaselineKey
) {
self.defaults = defaults
self.storageKey = storageKey
self.baselineKey = baselineKey
}
/// URI使
func load(menuItems: [HomeMenuItem]) -> [String] {
let availableURIs = Set(menuItems.map(\.uri))
if !defaults.bool(forKey: baselineKey) {
let defaults = defaultCommonURIs(availableURIs: availableURIs)
save(defaults)
self.defaults.set(true, forKey: baselineKey)
return defaults
}
let saved = defaults.stringArray(forKey: storageKey) ?? []
let filtered = unique(saved.compactMap { uri -> String? in
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
return availableURIs.contains(resolved) ? resolved : nil
})
save(filtered)
return filtered
}
/// URI
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem]) -> [String] {
let availableURIs = Set(menuItems.map(\.uri))
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
guard availableURIs.contains(resolved) else { return current }
guard !current.contains(where: { HomeMenuRouter.menuAliasKey(for: $0) == HomeMenuRouter.menuAliasKey(for: resolved) }) else {
return current
}
let next = current + [resolved]
save(next)
return next
}
/// URI URI
func remove(_ uri: String, current: [String]) -> [String] {
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey }
save(next)
return next
}
/// URI
func save(_ uris: [String]) {
defaults.set(unique(uris), forKey: storageKey)
}
/// URI
private func defaultCommonURIs(availableURIs: Set<String>) -> [String] {
let preferred = ["registration_invitation", "location_report", "pm_manager", "pm"]
return unique(preferred.compactMap { uri in
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
return availableURIs.contains(resolved) ? resolved : nil
})
}
/// URI URI
private func unique(_ uris: [String]) -> [String] {
var seen = Set<String>()
return uris.filter { uri in
seen.insert(HomeMenuRouter.menuAliasKey(for: uri)).inserted
}
}
}

View File

@ -0,0 +1,59 @@
//
// HomeMenuRouting.swift
// suixinkan
//
import UIKit
@MainActor
/// UIKit URI Tab Navigation push
enum HomeMenuRouting {
///
static func openMenu(_ item: HomeMenuItem, from viewController: UIViewController) {
openRoute(HomeMenuRouter.resolve(uri: item.uri, title: item.title), from: viewController)
}
/// URI
static func openRoute(_ route: HomeMenuResolvedRoute, from viewController: UIViewController) {
let services = AppServices.shared
switch route {
case .tab(let tab):
services.appRouter.select(tab)
case .orders(let entry):
services.appRouter.selectOrders(entry: entry)
case .destination(let homeRoute):
push(homeRoute, from: viewController)
case .unsupported(let uri, let title, _):
pushPlaceholder(title: title, uri: uri, from: viewController)
case .placeholder(let uri, let title):
HomeRouteDiagnostics.recordUnknown(uri: uri, title: title)
pushPlaceholder(title: title, uri: uri, from: viewController)
}
}
/// Push
static func push(_ route: HomeRoute, from viewController: UIViewController) {
let target = AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
viewController.navigationController?.pushViewController(target, animated: true)
}
/// Push
static func pushOrders(_ route: OrdersRoute, from viewController: UIViewController) {
let target = AppRouteViewControllerFactory.makeViewController(for: .orders(route), services: AppServices.shared)
viewController.navigationController?.pushViewController(target, animated: true)
}
/// Push
static func pushProfile(_ route: ProfileRoute, from viewController: UIViewController) {
let target = AppRouteViewControllerFactory.makeViewController(for: .profile(route), services: AppServices.shared)
viewController.navigationController?.pushViewController(target, animated: true)
}
private static func pushPlaceholder(title: String, uri: String, from viewController: UIViewController) {
viewController.navigationController?.pushViewController(
FeaturePlaceholderViewController(title: title, uri: uri),
animated: true
)
}
}

View File

@ -0,0 +1,260 @@
//
// HomeMoreFunctionsViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class HomeMoreFunctionsViewController: UIViewController {
private let viewModel = HomeViewModel()
private let commonMenuStore = HomeCommonMenuStore()
private var commonURIs: [String] = []
private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .grouped)
table.backgroundColor = AppDesignUIKit.pageBackground
table.separatorStyle = .none
table.dataSource = self
table.delegate = self
table.register(HomeMoreMenuGridCell.self, forCellReuseIdentifier: HomeMoreMenuGridCell.reuseID)
return table
}()
override func viewDidLoad() {
super.viewDidLoad()
title = "全部功能"
view.backgroundColor = AppDesignUIKit.pageBackground
navigationItem.largeTitleDisplayMode = .never
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
viewModel.onChange = { [weak self] in
self?.tableView.reloadData()
}
rebuildMenus()
appServices.permissionContext.onChange = { [weak self] in
self?.rebuildMenus()
}
}
private func rebuildMenus() {
let services = appServices
viewModel.buildMenus(
from: services.permissionContext.rolePermissions,
currentRoleId: services.permissionContext.currentRole?.id
)
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
tableView.reloadData()
}
private var commonItems: [HomeMenuItem] {
commonURIs.compactMap { menuItem(for: $0) }
}
private var moreItems: [HomeMenuItem] {
viewModel.menuItems.filter { !isCommonURI($0.uri) }
}
private func menuItem(for uri: String) -> HomeMenuItem? {
let availableURIs = Set(viewModel.menuItems.map(\.uri))
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
guard let existing = viewModel.menuItems.first(where: { $0.uri == resolvedUri }) else { return nil }
return HomeMenuItem(
title: HomeMenuRouter.displayTitle(for: resolvedUri, fallback: existing.title),
uri: resolvedUri,
iconSrc: existing.iconSrc
)
}
private func isCommonURI(_ uri: String) -> Bool {
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
return commonURIs.contains { HomeMenuRouter.menuAliasKey(for: $0) == aliasKey }
}
private func toggleCommon(_ item: HomeMenuItem, isCommon: Bool) {
if isCommon {
commonURIs = commonMenuStore.remove(item.uri, current: commonURIs)
} else {
commonURIs = commonMenuStore.add(item.uri, current: commonURIs, menuItems: viewModel.menuItems)
}
tableView.reloadData()
}
private func openMenu(_ item: HomeMenuItem) {
let route = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
if case .destination(let homeRoute) = route, homeRoute == .moreFunctions { return }
HomeMenuRouting.openRoute(route, from: self)
}
}
extension HomeMoreFunctionsViewController: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int { 2 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 0 ? "常用应用" : "更多功能"
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: HomeMoreMenuGridCell.reuseID, for: indexPath) as! HomeMoreMenuGridCell
let isCommon = indexPath.section == 0
let items = isCommon ? commonItems : moreItems
cell.configure(items: items, isCommon: isCommon) { [weak self] item in
self?.openMenu(item)
} onToggle: { [weak self] item in
self?.toggleCommon(item, isCommon: isCommon)
}
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
let count = indexPath.section == 0 ? commonItems.count : moreItems.count
let rows = max(1, Int(ceil(Double(count) / 3.0)))
return CGFloat(rows) * 124 + 8
}
}
private final class HomeMoreMenuGridCell: UITableViewCell {
static let reuseID = "HomeMoreMenuGridCell"
private var items: [HomeMenuItem] = []
private var isCommon = false
private var onSelect: ((HomeMenuItem) -> Void)?
private var onToggle: ((HomeMenuItem) -> Void)?
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.minimumInteritemSpacing = 14
layout.minimumLineSpacing = AppMetrics.Spacing.mediumLarge
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
view.backgroundColor = .clear
view.isScrollEnabled = false
view.dataSource = self
view.delegate = self
view.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID)
return view
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppMetrics.Spacing.mediumLarge)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(
items: [HomeMenuItem],
isCommon: Bool,
onSelect: @escaping (HomeMenuItem) -> Void,
onToggle: @escaping (HomeMenuItem) -> Void
) {
self.items = items
self.isCommon = isCommon
self.onSelect = onSelect
self.onToggle = onToggle
collectionView.reloadData()
}
}
extension HomeMoreMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
items.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMoreMenuItemCell.reuseID, for: indexPath) as! HomeMoreMenuItemCell
let item = items[indexPath.item]
cell.configure(item: item, isCommon: isCommon) { [weak self] in
self?.onToggle?(item)
}
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
onSelect?(items[indexPath.item])
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let width = (collectionView.bounds.width - 28) / 3
return CGSize(width: width, height: 112)
}
}
private final class HomeMoreMenuItemCell: UICollectionViewCell {
static let reuseID = "HomeMoreMenuItemCell"
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let toggleButton = UIButton(type: .system)
private var onToggle: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 8
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.callout)
titleLabel.textColor = UIColor(hex: 0x252525)
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 1
titleLabel.adjustsFontSizeToFitWidth = true
toggleButton.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside)
contentView.addSubview(toggleButton)
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
stack.axis = .vertical
stack.spacing = AppMetrics.Spacing.mediumLarge + 1
stack.alignment = .center
contentView.addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(4)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(34)
}
toggleButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(2)
make.width.height.equalTo(28)
}
iconView.contentMode = .scaleAspectFit
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(item: HomeMenuItem, isCommon: Bool, onToggle: @escaping () -> Void) {
self.onToggle = onToggle
titleLabel.text = item.title
let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri))
iconView.loadRemoteImage(urlString: item.iconSrc, contentMode: .scaleAspectFit, placeholder: symbol)
iconView.tintColor = AppDesign.primary
let iconName = isCommon ? "minus.circle.fill" : "plus.circle.fill"
toggleButton.setImage(UIImage(systemName: iconName), for: .normal)
toggleButton.tintColor = isCommon ? UIColor(hex: 0xFF1111) : AppDesignUIKit.primary
}
@objc private func toggleTapped() {
onToggle?()
}
}

View File

@ -0,0 +1,63 @@
//
// HomePlaceholderViewController.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import SnapKit
import UIKit
/// UIKit
final class HomePlaceholderViewController: UIViewController {
private let pageTitle: String
private let uri: String?
init(title: String, uri: String? = nil) {
pageTitle = title
self.uri = uri
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = pageTitle
view.backgroundColor = UIColor(hex: 0xF5F7FA)
let iconView = UIImageView(image: UIImage(systemName: "square.grid.2x2"))
iconView.tintColor = AppDesign.primary
iconView.contentMode = .scaleAspectFit
let titleLabel = UILabel()
titleLabel.text = pageTitle
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textColor = AppDesign.textPrimary
titleLabel.textAlignment = .center
let uriLabel = UILabel()
uriLabel.text = uri
uriLabel.font = .systemFont(ofSize: 14)
uriLabel.textColor = AppDesign.textSecondary
uriLabel.textAlignment = .center
uriLabel.numberOfLines = 2
uriLabel.isHidden = uri?.isEmpty != false
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel, uriLabel])
stack.axis = .vertical
stack.spacing = 12
stack.alignment = .center
view.addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(24)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(44)
}
}
}

View File

@ -0,0 +1,583 @@
//
// HomeViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class HomeViewController: UIViewController {
private let viewModel = HomeViewModel()
private let commonMenuStore = HomeCommonMenuStore()
private var commonURIs: [String] = []
private var isOnline = false
private var reminderMinutes = 0
private var secondsUntilReport = 0
private var countdownTimer: Timer?
private let minimalTopRoleIds: Set<Int> = [46, 47, 52, 53, 54]
private lazy var scenicButton: UIButton = {
var config = UIButton.Configuration.plain()
config.baseForegroundColor = UIColor(hex: 0x333333)
config.image = UIImage(systemName: "chevron.down")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: AppMetrics.FontSize.subheadline, weight: .bold)
)
config.imagePlacement = .trailing
config.imagePadding = AppMetrics.Spacing.xSmall
config.contentInsets = .zero
let button = UIButton(configuration: config)
button.contentHorizontalAlignment = .leading
button.addTarget(self, action: #selector(scenicTapped), for: .touchUpInside)
return button
}()
private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .grouped)
table.backgroundColor = AppDesignUIKit.pageBackground
table.separatorStyle = .none
table.showsVerticalScrollIndicator = false
table.dataSource = self
table.delegate = self
table.register(HomeMenuGridCell.self, forCellReuseIdentifier: HomeMenuGridCell.reuseID)
table.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
return table
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = AppDesignUIKit.pageBackground
setupTopBar()
setupTableView()
bindViewModel()
rebuildMenus()
observeContextChanges()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
startCountdownTimerIfNeeded()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
countdownTimer?.invalidate()
countdownTimer = nil
}
private func setupTopBar() {
let topBar = UIView()
topBar.backgroundColor = .white
view.addSubview(topBar)
topBar.addSubview(scenicButton)
topBar.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.height.equalTo(78)
}
scenicButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
make.centerY.equalToSuperview()
}
updateScenicTitle()
}
private func setupTableView() {
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(78)
make.leading.trailing.bottom.equalToSuperview()
}
}
private func bindViewModel() {
viewModel.onChange = { [weak self] in
self?.tableView.reloadData()
}
}
private func observeContextChanges() {
let services = appServices
services.permissionContext.onChange = { [weak self] in
self?.rebuildMenus()
}
services.accountContext.onChange = { [weak self] in
self?.updateScenicTitle()
self?.tableView.reloadData()
}
}
private func rebuildMenus() {
let services = appServices
viewModel.buildMenus(
from: services.permissionContext.rolePermissions,
currentRoleId: services.permissionContext.currentRole?.id
)
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
tableView.reloadData()
}
private func updateScenicTitle() {
let name = appServices.accountContext.currentScenic?.name ?? "请选择景区"
scenicButton.configuration?.attributedTitle = AttributedString(
name,
attributes: AttributeContainer([
.font: UIFont.systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
])
)
}
private var currentRoleId: Int? {
appServices.permissionContext.currentRole?.id
}
private var shouldShowWorkStatus: Bool {
guard let currentRoleId else { return true }
return !minimalTopRoleIds.contains(currentRoleId)
}
private var isStoreManager: Bool {
currentRoleId == 46
}
private var displayMenuItems: [HomeMenuItem] {
let selected = commonURIs.compactMap { menuItem(for: $0) }
let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected
return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
}
private func menuItem(for uri: String) -> HomeMenuItem? {
let availableURIs = Set(viewModel.menuItems.map(\.uri))
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
guard let existing = viewModel.menuItems.first(where: { $0.uri == resolvedUri }) else { return nil }
return HomeMenuItem(
title: HomeMenuRouter.displayTitle(for: resolvedUri, fallback: existing.title),
uri: resolvedUri,
iconSrc: existing.iconSrc
)
}
private var countdownDisplay: String {
let hours = secondsUntilReport / 3_600
let minutes = (secondsUntilReport % 3_600) / 60
let seconds = secondsUntilReport % 60
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
}
private var reminderText: String {
reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟"
}
private func startCountdownTimerIfNeeded() {
countdownTimer?.invalidate()
guard isOnline else { return }
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self, self.isOnline, self.secondsUntilReport > 0 else { return }
self.secondsUntilReport -= 1
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
}
}
@objc private func scenicTapped() {
HomeMenuRouting.push(.scenicSelection, from: self)
}
@objc private func onlineTapped() {
let message = isOnline
? "是否确认切换为离线状态?离线后将暂停位置上报。"
: "是否确认切换为在线状态?在线后将开始位置上报和计时。"
let alert = UIAlertController(title: "切换在线状态", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
guard let self else { return }
self.isOnline.toggle()
if self.isOnline {
self.secondsUntilReport = 7_200
self.startCountdownTimerIfNeeded()
} else {
self.countdownTimer?.invalidate()
}
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
})
present(alert, animated: true)
}
@objc private func reminderTapped() {
let sheet = UIAlertController(title: "提前提醒时间", message: nil, preferredStyle: .actionSheet)
for minute in [0, 5, 10, 15, 30] {
let title = minute == 0 ? "不提醒" : "\(minute)分钟"
sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
self?.reminderMinutes = minute
self?.tableView.reloadSections(IndexSet(integer: 0), with: .none)
})
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func locationReportTapped() {
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报"), from: self)
}
@objc private func paymentTapped() {
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"), from: self)
}
@objc private func taskCreateTapped() {
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"), from: self)
}
}
// MARK: - UITableView
extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {
var count = 1
if shouldShowWorkStatus { count += 2 }
if isStoreManager, appServices.accountContext.currentStore != nil { count += 1 }
return count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
1
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
if section == tableView.numberOfSections - 1 {
return "常用应用"
}
return nil
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if indexPath.section == tableView.numberOfSections - 1 {
let cell = tableView.dequeueReusableCell(withIdentifier: HomeMenuGridCell.reuseID, for: indexPath) as! HomeMenuGridCell
cell.configure(items: displayMenuItems) { [weak self] item in
self.flatMap { HomeMenuRouting.openMenu(item, from: $0) }
}
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.selectionStyle = .none
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
if shouldShowWorkStatus {
if indexPath.section == 0 {
cell.contentView.addSubview(makeStatusCard())
} else if indexPath.section == 1 {
cell.contentView.addSubview(makeLocationCard())
} else if indexPath.section == 2, isStoreManager, let store = appServices.accountContext.currentStore {
cell.contentView.addSubview(makeStoreCard(store))
} else {
cell.contentView.addSubview(makeQuickActionsRow())
}
} else if isStoreManager, let store = appServices.accountContext.currentStore, indexPath.section == 0 {
cell.contentView.addSubview(makeStoreCard(store))
}
cell.contentView.subviews.first?.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(
top: AppMetrics.Spacing.xxSmall,
left: AppMetrics.Spacing.pageHorizontal,
bottom: AppMetrics.Spacing.xxSmall,
right: AppMetrics.Spacing.pageHorizontal
))
}
cell.backgroundColor = .clear
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if indexPath.section == tableView.numberOfSections - 1 { return 240 }
if shouldShowWorkStatus {
switch indexPath.section {
case 0: return 100
case 1: return 148
case 2 where isStoreManager && appServices.accountContext.currentStore != nil: return 88
default: return 118
}
}
if isStoreManager, appServices.accountContext.currentStore != nil, indexPath.section == 0 { return 88 }
return UITableView.automaticDimension
}
private func makeStatusCard() -> UIView {
let card = makeCardView()
let onlineButton = UIButton(type: .system)
onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal)
onlineButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium)
onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal)
onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4)
onlineButton.layer.cornerRadius = 4
onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside)
let clockLabel = UILabel()
clockLabel.text = " \(countdownDisplay)"
clockLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
clockLabel.textColor = AppDesignUIKit.primary
let reminderButton = UIButton(type: .system)
reminderButton.setTitle(" \(reminderText)", for: .normal)
reminderButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
reminderButton.setTitleColor(AppDesignUIKit.primary, for: .normal)
reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [onlineButton, clockLabel, reminderButton])
stack.axis = .horizontal
stack.distribution = .equalSpacing
stack.alignment = .center
card.addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(15)
}
return card
}
private func makeLocationCard() -> UIView {
let card = makeCardView()
let titleLabel = UILabel()
titleLabel.text = "立即上报"
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
titleLabel.textColor = UIColor(hex: 0x333333)
let subtitleLabel = UILabel()
subtitleLabel.text = "您已进入打卡范围"
subtitleLabel.font = .systemFont(ofSize: 15)
subtitleLabel.textColor = UIColor(hex: 0x999999)
let textStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
textStack.axis = .vertical
textStack.spacing = AppMetrics.Spacing.xxSmall
let actionButton = UIButton(type: .system)
actionButton.setImage(UIImage(systemName: "hand.tap.fill"), for: .normal)
actionButton.tintColor = .white
actionButton.backgroundColor = AppDesignUIKit.primary
actionButton.layer.cornerRadius = 46
actionButton.addTarget(self, action: #selector(locationReportTapped), for: .touchUpInside)
card.addSubview(textStack)
card.addSubview(actionButton)
textStack.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(15)
make.trailing.lessThanOrEqualTo(actionButton.snp.leading).offset(-12)
}
actionButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(15)
make.centerY.equalToSuperview()
make.width.height.equalTo(92)
}
return card
}
private func makeQuickActionsRow() -> UIView {
let stack = UIStackView()
stack.axis = .horizontal
stack.spacing = AppMetrics.Spacing.small
stack.distribution = .fillEqually
stack.addArrangedSubview(quickActionButton(icon: "qrcode", title: "立即收款", action: #selector(paymentTapped)))
stack.addArrangedSubview(quickActionButton(icon: "checklist.checked", title: "提交任务", action: #selector(taskCreateTapped)))
stack.addArrangedSubview(quickActionButton(
icon: isOnline ? "wifi" : "wifi.slash",
title: isOnline ? "在线" : "离线",
action: #selector(onlineTapped),
active: isOnline
))
return stack
}
private func quickActionButton(icon: String, title: String, action: Selector, active: Bool = false) -> UIView {
let card = makeCardView()
card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white
let iconView = UIImageView(image: UIImage(systemName: icon))
iconView.tintColor = AppDesignUIKit.primary
iconView.contentMode = .scaleAspectFit
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: AppMetrics.FontSize.body)
label.textAlignment = .center
let stack = UIStackView(arrangedSubviews: [iconView, label])
stack.axis = .vertical
stack.spacing = AppMetrics.Spacing.xSmall
stack.alignment = .center
card.addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
}
iconView.snp.makeConstraints { make in
make.height.equalTo(34)
}
let button = UIButton(type: .custom)
button.addTarget(self, action: action, for: .touchUpInside)
card.addSubview(button)
button.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
card.snp.makeConstraints { make in
make.height.equalTo(102)
}
return card
}
private func makeStoreCard(_ store: BusinessScope) -> UIView {
let card = makeCardView()
let nameLabel = UILabel()
nameLabel.text = store.name
nameLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .bold)
let scenicLabel = UILabel()
scenicLabel.text = appServices.accountContext.currentScenic?.name
scenicLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
scenicLabel.textColor = UIColor(hex: 0x7B8EAA)
scenicLabel.numberOfLines = 2
let statusLabel = UILabel()
statusLabel.text = "营业中"
statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
statusLabel.textColor = UIColor(hex: 0x22C55E)
statusLabel.backgroundColor = UIColor(hex: 0xF0FDF4)
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center
let textStack = UIStackView(arrangedSubviews: [nameLabel, scenicLabel])
textStack.axis = .vertical
textStack.spacing = AppMetrics.Spacing.xSmall
card.addSubview(textStack)
card.addSubview(statusLabel)
textStack.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8)
}
statusLabel.snp.makeConstraints { make in
make.trailing.top.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.width.greaterThanOrEqualTo(52)
make.height.equalTo(22)
}
return card
}
}
// MARK: - Menu Grid Cell
private final class HomeMenuGridCell: UITableViewCell {
static let reuseID = "HomeMenuGridCell"
private var onSelect: ((HomeMenuItem) -> Void)?
private var items: [HomeMenuItem] = []
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.minimumInteritemSpacing = 15
layout.minimumLineSpacing = 15
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
view.backgroundColor = .clear
view.isScrollEnabled = false
view.dataSource = self
view.delegate = self
view.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID)
return view
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
make.height.equalTo(220)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(items: [HomeMenuItem], onSelect: @escaping (HomeMenuItem) -> Void) {
self.items = items
self.onSelect = onSelect
collectionView.reloadData()
}
}
extension HomeMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
items.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMenuItemCell.reuseID, for: indexPath) as! HomeMenuItemCell
cell.configure(item: items[indexPath.item])
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
onSelect?(items[indexPath.item])
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let width = (collectionView.bounds.width - 30) / 3
return CGSize(width: width, height: 102)
}
}
private final class HomeMenuItemCell: UICollectionViewCell {
static let reuseID = "HomeMenuItemCell"
private let iconView = UIImageView()
private let titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 8
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body)
titleLabel.textColor = UIColor(hex: 0x4B5563)
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 1
titleLabel.adjustsFontSizeToFitWidth = true
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
stack.axis = .vertical
stack.spacing = AppMetrics.Spacing.small
stack.alignment = .center
contentView.addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(4)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(30)
}
iconView.contentMode = .scaleAspectFit
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(item: HomeMenuItem) {
titleLabel.text = item.title
let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri))
iconView.loadRemoteImage(urlString: item.iconSrc, contentMode: .scaleAspectFit, placeholder: symbol)
iconView.tintColor = AppDesign.primary
}
}

View File

@ -0,0 +1,115 @@
//
// HomeViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
@MainActor
/// ViewModel
final class HomeViewModel {
var onChange: (() -> Void)?
private(set) var menuItems: [HomeMenuItem] = [] { didSet { onChange?() } }
/// Android
private let preferredOrder: [String] = [
"space_settings",
"album_list",
"album_trailer",
"wallet",
"cloud_management",
"cloud_storage_transit",
"asset_management",
"material_upload",
"task_management",
"schedule_management",
"system_settings",
"message_center",
"checkin_points",
"sample_management",
"sample_upload",
"live_stream_management",
"scenicselection",
"scenicapplication",
"permission_apply",
"permission_apply_status",
"scenic_settlement",
"scenic_settlement_review",
"verification_order",
"live_album",
"pm",
"location_report",
"registration_invitation",
"store",
"fly",
"pilot_cert",
"task_management_editor",
"deposit_order_detail",
"deposit_order",
"pm_manager",
"pilot_controller",
"/scenic-queue",
"queue_management",
"operating-area",
"/scenic-order-manage",
"photographer_orders"
]
/// URI
func buildMenus(from permissions: [RolePermissionResponse], currentRoleId: Int?) {
let source: RolePermissionResponse?
if let currentRoleId {
source = permissions.first { $0.role.id == currentRoleId }
} else {
source = permissions.first
}
guard let source else {
menuItems = []
return
}
let permissionItems = flatten(source.role.permission)
var seen = Set<String>()
var deduplicated: [PermissionItem] = []
for item in permissionItems where !item.uri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if seen.insert(HomeMenuRouter.menuAliasKey(for: item.uri)).inserted {
deduplicated.append(item)
}
}
let preferredIndexMap = Dictionary(uniqueKeysWithValues: preferredOrder.enumerated().map { ($1, $0) })
let sortedItems = deduplicated.sorted { lhs, rhs in
let lhsRank = preferredIndexMap[lhs.uri] ?? Int.max
let rhsRank = preferredIndexMap[rhs.uri] ?? Int.max
if lhsRank != rhsRank {
return lhsRank < rhsRank
}
let lhsIndex = deduplicated.firstIndex(where: { $0.id == lhs.id }) ?? Int.max
let rhsIndex = deduplicated.firstIndex(where: { $0.id == rhs.id }) ?? Int.max
return lhsIndex < rhsIndex
}
menuItems = sortedItems.map { item in
HomeMenuItem(
title: item.name.isEmpty ? HomeMenuRouter.title(for: item.uri) : item.name,
uri: item.uri,
iconSrc: item.iconSrc
)
}
}
/// URI
func title(for uri: String) -> String {
HomeMenuRouter.title(for: uri)
}
///
private func flatten(_ items: [PermissionItem]) -> [PermissionItem] {
items.flatMap { item in
[item] + flatten(item.children)
}
}
}