初始提交
This commit is contained in:
36
suixinkan/Features/Home/Home.md
Normal file
36
suixinkan/Features/Home/Home.md
Normal file
@ -0,0 +1,36 @@
|
||||
# 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` 进行页面跳转。
|
||||
|
||||
## 后续迁移
|
||||
|
||||
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
||||
62
suixinkan/Features/Home/Models/HomeMenuItem.swift
Normal file
62
suixinkan/Features/Home/Models/HomeMenuItem.swift
Normal file
@ -0,0 +1,62 @@
|
||||
//
|
||||
// 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 moreFunctions
|
||||
case modulePlaceholder(uri: String, title: String)
|
||||
}
|
||||
|
||||
/// 首页权限 URI 解析结果,区分 Tab 跳转、本地页面、已知不支持和未知占位。
|
||||
enum HomeMenuResolvedRoute: Equatable {
|
||||
case tab(AppTab)
|
||||
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
|
||||
}
|
||||
}
|
||||
412
suixinkan/Features/Home/Routing/HomeMenuRouter.swift
Normal file
412
suixinkan/Features/Home/Routing/HomeMenuRouter.swift
Normal file
@ -0,0 +1,412 @@
|
||||
//
|
||||
// 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 .tab(.orders)
|
||||
case "verification_order":
|
||||
return .tab(.orders)
|
||||
case "photographer_stats":
|
||||
return .tab(.statistics)
|
||||
case "space_settings", "basic_info":
|
||||
return .destination(.profileSpace)
|
||||
case "scenicselection":
|
||||
return .destination(.scenicSelection)
|
||||
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 迁移和后续开发范围。"
|
||||
)
|
||||
case "wallet",
|
||||
"message_center",
|
||||
"/scenic-queue",
|
||||
"queue_management",
|
||||
"payment_collection",
|
||||
"payment_qr",
|
||||
"payment_code",
|
||||
"deposit_order_detail",
|
||||
"deposit_order",
|
||||
"deposit_order_shooting_info",
|
||||
"withdrawal_audit",
|
||||
"task_management",
|
||||
"task_management_editor",
|
||||
"task_create",
|
||||
"schedule_management",
|
||||
"checkin_points",
|
||||
"pm",
|
||||
"pm_manager",
|
||||
"project_edit",
|
||||
"cloud_management",
|
||||
"cloud_storage_transit",
|
||||
"album_list",
|
||||
"album_trailer",
|
||||
"asset_management",
|
||||
"material_upload",
|
||||
"sample_management",
|
||||
"sample_upload",
|
||||
"live_stream_management",
|
||||
"live_album",
|
||||
"scenicapplication",
|
||||
"permission_apply",
|
||||
"permission_apply_status",
|
||||
"scenic_settlement",
|
||||
"scenic_settlement_review",
|
||||
"operating-area",
|
||||
"location_report_history",
|
||||
"location_report",
|
||||
"registration_invitation",
|
||||
"photographer_invite",
|
||||
"invite_record",
|
||||
"system_settings",
|
||||
"pilot_cert":
|
||||
let resolvedTitle = title.isEmpty ? self.title(for: uri) : title
|
||||
return .destination(.modulePlaceholder(uri: uri, title: resolvedTitle))
|
||||
default:
|
||||
return .placeholder(uri: uri, title: title.isEmpty ? self.title(for: uri) : title)
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回指定 URI 的默认标题。
|
||||
static func title(for uri: String) -> String {
|
||||
titleMap[uri] ?? uri
|
||||
}
|
||||
|
||||
/// 在可用 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("pm_manager") ? "pm_manager" : uri
|
||||
case "pm_manager":
|
||||
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", "pm_manager", "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", "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, .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: " ")
|
||||
}
|
||||
}
|
||||
91
suixinkan/Features/Home/Services/HomeCommonMenuStore.swift
Normal file
91
suixinkan/Features/Home/Services/HomeCommonMenuStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
116
suixinkan/Features/Home/ViewModels/HomeViewModel.swift
Normal file
116
suixinkan/Features/Home/ViewModels/HomeViewModel.swift
Normal file
@ -0,0 +1,116 @@
|
||||
//
|
||||
// HomeViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 首页 ViewModel,根据当前角色权限构建首页菜单列表。
|
||||
final class HomeViewModel {
|
||||
private(set) var menuItems: [HomeMenuItem] = []
|
||||
|
||||
/// 菜单排序权重,与旧工程和 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
87
suixinkan/Features/Home/Views/HomeIconCatalog.swift
Normal file
87
suixinkan/Features/Home/Views/HomeIconCatalog.swift
Normal file
@ -0,0 +1,87 @@
|
||||
//
|
||||
// HomeIconCatalog.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
213
suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift
Normal file
213
suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift
Normal file
@ -0,0 +1,213 @@
|
||||
//
|
||||
// HomeMoreFunctionsView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 首页全部功能视图,展示常用应用和当前角色拥有的更多功能。
|
||||
struct HomeMoreFunctionsView: View {
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = HomeViewModel()
|
||||
@State private var commonUris: [String] = []
|
||||
|
||||
private let commonMenuStore = HomeCommonMenuStore()
|
||||
|
||||
private var commonItems: [HomeMenuItem] {
|
||||
commonUris.compactMap(menuItem(for:))
|
||||
}
|
||||
|
||||
private var moreItems: [HomeMenuItem] {
|
||||
viewModel.menuItems.filter { item in
|
||||
!isCommonURI(item.uri)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 26) {
|
||||
section(title: "常用应用", items: commonItems, isCommon: true)
|
||||
section(title: "更多功能", items: moreItems, isCommon: false)
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.mediumLarge)
|
||||
.padding(.top, AppMetrics.Spacing.xxLarge + 1)
|
||||
.padding(.bottom, AppMetrics.Spacing.xxLarge)
|
||||
}
|
||||
.background(Color(hex: 0xF5F5F5))
|
||||
}
|
||||
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.task {
|
||||
rebuildMenus()
|
||||
}
|
||||
.onChange(of: permissionContext.currentRole?.id) { _, _ in
|
||||
rebuildMenus()
|
||||
}
|
||||
.onChange(of: permissionContext.rolePermissions.count) { _, _ in
|
||||
rebuildMenus()
|
||||
}
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
ZStack {
|
||||
Text("全部功能")
|
||||
.font(.system(size: AppMetrics.FontSize.title2 + 1, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x2C2C2C))
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x111827))
|
||||
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
}
|
||||
.frame(height: 62)
|
||||
.background(.white)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(Color.black.opacity(0.06))
|
||||
.frame(height: 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
private func section(title: String, items: [HomeMenuItem], isCommon: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.title, weight: .bold))
|
||||
.foregroundStyle(Color(hex: 0x2C2C2C))
|
||||
|
||||
appGrid(items: items, isCommon: isCommon)
|
||||
}
|
||||
}
|
||||
|
||||
private func appGrid(items: [HomeMenuItem], isCommon: Bool) -> some View {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 14), count: 3), spacing: AppMetrics.Spacing.mediumLarge) {
|
||||
ForEach(items, id: \.uri) { item in
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Button {
|
||||
openMenu(item)
|
||||
} label: {
|
||||
VStack(spacing: AppMetrics.Spacing.mediumLarge + 1) {
|
||||
menuIconView(for: item)
|
||||
.frame(width: 34, height: 34)
|
||||
|
||||
Text(item.title)
|
||||
.font(.system(size: AppMetrics.FontSize.callout))
|
||||
.foregroundStyle(Color(hex: 0x252525))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 112)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
if isCommon {
|
||||
commonUris = commonMenuStore.remove(item.uri, current: commonUris)
|
||||
} else {
|
||||
commonUris = commonMenuStore.add(item.uri, current: commonUris, menuItems: viewModel.menuItems)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isCommon ? "minus.circle.fill" : "plus.circle.fill")
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(isCommon ? Color(hex: 0xFF1111) : AppDesign.primary)
|
||||
.background(Color.white, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.offset(x: 9, y: -9)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 112)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func menuIconView(for item: HomeMenuItem) -> some View {
|
||||
if let src = item.iconSrc,
|
||||
let url = URL(string: src),
|
||||
!src.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFit()
|
||||
default:
|
||||
fallbackMenuIcon(for: item.uri)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fallbackMenuIcon(for: item.uri)
|
||||
}
|
||||
}
|
||||
|
||||
private func fallbackMenuIcon(for uri: String) -> some View {
|
||||
Image(systemName: HomeIconCatalog.iconName(for: uri))
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(width: 34, height: 34)
|
||||
}
|
||||
|
||||
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 openMenu(_ item: HomeMenuItem) {
|
||||
switch HomeMenuRouter.resolve(uri: item.uri, title: item.title) {
|
||||
case .tab(let tab):
|
||||
appRouter.select(tab)
|
||||
case .destination(let route):
|
||||
if route == .moreFunctions {
|
||||
return
|
||||
}
|
||||
router.navigate(to: .home(route))
|
||||
case .unsupported(let uri, let title, _):
|
||||
router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title)))
|
||||
case .placeholder(let uri, let title):
|
||||
HomeRouteDiagnostics.recordUnknown(uri: uri, title: title)
|
||||
router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title)))
|
||||
}
|
||||
}
|
||||
|
||||
private func rebuildMenus() {
|
||||
viewModel.buildMenus(
|
||||
from: permissionContext.rolePermissions,
|
||||
currentRoleId: permissionContext.currentRole?.id
|
||||
)
|
||||
commonUris = commonMenuStore.load(menuItems: viewModel.menuItems)
|
||||
}
|
||||
}
|
||||
82
suixinkan/Features/Home/Views/HomeSupportViews.swift
Normal file
82
suixinkan/Features/Home/Views/HomeSupportViews.swift
Normal file
@ -0,0 +1,82 @@
|
||||
//
|
||||
// HomeSupportViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension HomeRoute {
|
||||
/// 将首页路由映射为真实页面或迁移占位页。
|
||||
@ViewBuilder
|
||||
var destinationView: some View {
|
||||
switch self {
|
||||
case .profileSpace:
|
||||
ProfileView()
|
||||
case .scenicSelection:
|
||||
HomeScenicSelectionView()
|
||||
case .moreFunctions:
|
||||
HomeMoreFunctionsView()
|
||||
case let .modulePlaceholder(uri, title):
|
||||
HomeMigrationModuleView(title: title, uri: uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页迁移占位视图,用于承接尚未同步的功能入口。
|
||||
struct HomeMigrationModuleView: View {
|
||||
let title: String
|
||||
let uri: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
Image(systemName: "square.grid.2x2")
|
||||
.font(.system(size: 44, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.title2, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
|
||||
Text(uri)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, AppMetrics.Spacing.large)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页景区选择视图,允许用户切换当前账号上下文中的景区。
|
||||
struct HomeScenicSelectionView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
List(accountContext.scenicScopes) { scenic in
|
||||
Button {
|
||||
accountContext.selectScenic(id: scenic.id)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(scenic.name)
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Spacer()
|
||||
if accountContext.currentScenic?.id == scenic.id {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: AppMetrics.ControlSize.smallIcon, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("景区选择")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
443
suixinkan/Features/Home/Views/HomeView.swift
Normal file
443
suixinkan/Features/Home/Views/HomeView.swift
Normal file
@ -0,0 +1,443 @@
|
||||
//
|
||||
// HomeView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// 首页主视图,展示景区、工作状态、快捷操作和权限菜单入口。
|
||||
struct HomeView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@State private var viewModel = HomeViewModel()
|
||||
@State private var commonUris: [String] = []
|
||||
@State private var isOnline = false
|
||||
@State private var reminderMinutes = 0
|
||||
@State private var secondsUntilReport = 0
|
||||
@State private var showOnlineDialog = false
|
||||
@State private var showReminderDialog = false
|
||||
@State private var showReportSuccess = false
|
||||
|
||||
private let commonMenuStore = HomeCommonMenuStore()
|
||||
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
private let minimalTopRoleIds: Set<Int> = [46, 47, 52, 53, 54]
|
||||
|
||||
private var contentMaxWidth: CGFloat {
|
||||
horizontalSizeClass == .regular ? 760 : .infinity
|
||||
}
|
||||
|
||||
private var currentRoleId: Int? {
|
||||
permissionContext.currentRole?.id
|
||||
}
|
||||
|
||||
private var isStoreManager: Bool {
|
||||
currentRoleId == 46
|
||||
}
|
||||
|
||||
private var shouldShowWorkStatus: Bool {
|
||||
guard let currentRoleId else { return true }
|
||||
return !minimalTopRoleIds.contains(currentRoleId)
|
||||
}
|
||||
|
||||
private var currentScenicName: String {
|
||||
accountContext.currentScenic?.name ?? "请选择景区"
|
||||
}
|
||||
|
||||
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 var tileHeight: CGFloat {
|
||||
horizontalSizeClass == .regular ? 118 : 102
|
||||
}
|
||||
|
||||
private var displayMenuItems: [HomeMenuItem] {
|
||||
let selected = commonUris.compactMap(menuItemForHomeEntry(uri:))
|
||||
let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected
|
||||
return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
if shouldShowWorkStatus {
|
||||
statusCard
|
||||
locationReportCard
|
||||
quickActionsRow
|
||||
}
|
||||
|
||||
if isStoreManager, let store = accountContext.currentStore {
|
||||
storeCard(store)
|
||||
.padding(.bottom, AppMetrics.Spacing.xSmall)
|
||||
}
|
||||
|
||||
Text("常用应用")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .bold))
|
||||
.foregroundStyle(Color(hex: 0x333333))
|
||||
.padding(.top, shouldShowWorkStatus ? AppMetrics.Spacing.xxSmall : AppMetrics.Spacing.xSmall)
|
||||
.padding(.bottom, AppMetrics.Spacing.xxSmall)
|
||||
|
||||
appGrid
|
||||
}
|
||||
.frame(maxWidth: contentMaxWidth, alignment: .topLeading)
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.top, AppMetrics.Spacing.xxLarge)
|
||||
.padding(.bottom, AppMetrics.Spacing.pageVertical)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.background(Color(hex: 0xF5F5F5))
|
||||
}
|
||||
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.task {
|
||||
rebuildMenusFromCurrentContext()
|
||||
}
|
||||
.onReceive(timer) { _ in
|
||||
guard isOnline, secondsUntilReport > 0 else { return }
|
||||
secondsUntilReport -= 1
|
||||
}
|
||||
.onChange(of: permissionContext.currentRole?.id) { _, _ in
|
||||
rebuildMenusFromCurrentContext()
|
||||
}
|
||||
.onChange(of: permissionContext.rolePermissions.count) { _, _ in
|
||||
rebuildMenusFromCurrentContext()
|
||||
}
|
||||
.alert("切换在线状态", isPresented: $showOnlineDialog) {
|
||||
Button("取消", role: .cancel) {}
|
||||
Button("确定") {
|
||||
isOnline.toggle()
|
||||
if isOnline {
|
||||
secondsUntilReport = 7_200
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。")
|
||||
}
|
||||
.confirmationDialog("提前提醒时间", isPresented: $showReminderDialog, titleVisibility: .visible) {
|
||||
ForEach([0, 5, 10, 15, 30], id: \.self) { minute in
|
||||
Button(minute == 0 ? "不提醒" : "\(minute)分钟") {
|
||||
reminderMinutes = minute
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.alert("上报成功", isPresented: $showReportSuccess) {
|
||||
Button("知道了", role: .cancel) {}
|
||||
} message: {
|
||||
Text("上报已成功,距离下次上报时间 \(countdownDisplay)。")
|
||||
}
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
HStack {
|
||||
Button {
|
||||
router.navigate(to: .home(.scenicSelection))
|
||||
} label: {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(currentScenicName)
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x333333))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .bold))
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.frame(height: 78)
|
||||
.background(.white)
|
||||
}
|
||||
|
||||
private var statusCard: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Button {
|
||||
showOnlineDialog = true
|
||||
} label: {
|
||||
Text(isOnline ? "在线" : "离线")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(isOnline ? Color(hex: 0xF0FDF4) : Color(hex: 0x7B8EAA))
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.padding(.vertical, AppMetrics.Spacing.xxSmall + 2)
|
||||
.background(isOnline ? Color(hex: 0x22C55E) : Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer(minLength: AppMetrics.Spacing.xxSmall)
|
||||
|
||||
Label {
|
||||
Text(countdownDisplay)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
||||
} icon: {
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
|
||||
Spacer(minLength: AppMetrics.Spacing.xxSmall)
|
||||
|
||||
Button {
|
||||
showReminderDialog = true
|
||||
} label: {
|
||||
Label {
|
||||
Text(reminderText)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
||||
} icon: {
|
||||
Image(systemName: "bell.fill")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(15)
|
||||
.frame(minHeight: 84)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var locationReportCard: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Image(systemName: "arrow.up.square.fill")
|
||||
.font(.system(size: 25, weight: .bold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
Text("立即上报")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(Color(hex: 0x333333))
|
||||
}
|
||||
|
||||
Text("您已进入打卡范围")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Color(hex: 0x999999))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
secondsUntilReport = 7_200
|
||||
showReportSuccess = true
|
||||
} label: {
|
||||
Image(systemName: "hand.tap.fill")
|
||||
.font(.system(size: 38, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 92, height: 92)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color(hex: 0x0073FF), Color(hex: 0x5CA8FF)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
in: Circle()
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("上报位置")
|
||||
}
|
||||
.padding(15)
|
||||
.frame(minHeight: 132)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var quickActionsRow: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
quickAction(icon: "qrcode", title: "立即收款") {
|
||||
openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"))
|
||||
}
|
||||
|
||||
quickAction(icon: "checklist.checked", title: "提交任务") {
|
||||
openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"))
|
||||
}
|
||||
|
||||
quickAction(icon: isOnline ? "wifi" : "wifi.slash", title: isOnline ? "在线" : "离线", active: isOnline) {
|
||||
showOnlineDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func quickAction(icon: String, title: String, active: Bool = false, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(height: 34)
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.body))
|
||||
.foregroundStyle(Color.black)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: tileHeight)
|
||||
.background(active ? Color(hex: 0xE3F2FD) : .white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func storeCard(_ store: BusinessScope) -> some View {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(store.name)
|
||||
.font(.system(size: AppMetrics.FontSize.title2, weight: .bold))
|
||||
.foregroundStyle(.black)
|
||||
.lineLimit(1)
|
||||
|
||||
if let scenic = accountContext.currentScenic {
|
||||
Text(scenic.name)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(Color(hex: 0x7B8EAA))
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("营业中")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(Color(hex: 0x22C55E))
|
||||
.padding(.horizontal, AppMetrics.Spacing.xSmall + 2)
|
||||
.padding(.vertical, AppMetrics.Spacing.xxxSmall)
|
||||
.background(Color(hex: 0xF0FDF4), in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var appGrid: some View {
|
||||
LazyVGrid(columns: menuColumns, spacing: 15) {
|
||||
ForEach(displayMenuItems) { item in
|
||||
Button {
|
||||
openMenu(item)
|
||||
} label: {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
menuIconView(for: item)
|
||||
.frame(width: 30, height: 30)
|
||||
Text(item.title)
|
||||
.font(.system(size: AppMetrics.FontSize.body))
|
||||
.foregroundStyle(Color(hex: 0x4B5563))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: tileHeight)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var menuColumns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 15), count: 3)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func menuIconView(for item: HomeMenuItem) -> some View {
|
||||
if let src = item.iconSrc,
|
||||
let url = URL(string: src),
|
||||
!src.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
default:
|
||||
fallbackMenuIcon(for: item.uri)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fallbackMenuIcon(for: item.uri)
|
||||
}
|
||||
}
|
||||
|
||||
private func fallbackMenuIcon(for uri: String) -> some View {
|
||||
Image(systemName: iconName(for: uri))
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
|
||||
private func iconName(for uri: String) -> String {
|
||||
HomeIconCatalog.iconName(for: uri)
|
||||
}
|
||||
|
||||
private func menuItemForHomeEntry(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 openMenu(_ item: HomeMenuItem) {
|
||||
openRoute(HomeMenuRouter.resolve(uri: item.uri, title: item.title))
|
||||
}
|
||||
|
||||
private func openRoute(_ route: HomeMenuResolvedRoute) {
|
||||
switch route {
|
||||
case .tab(let tab):
|
||||
appRouter.select(tab)
|
||||
case .destination(let route):
|
||||
router.navigate(to: .home(route))
|
||||
case .unsupported(let uri, let title, _):
|
||||
router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title)))
|
||||
case .placeholder(let uri, let title):
|
||||
HomeRouteDiagnostics.recordUnknown(uri: uri, title: title)
|
||||
router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title)))
|
||||
}
|
||||
}
|
||||
|
||||
private func rebuildMenusFromCurrentContext() {
|
||||
viewModel.buildMenus(
|
||||
from: permissionContext.rolePermissions,
|
||||
currentRoleId: permissionContext.currentRole?.id
|
||||
)
|
||||
commonUris = commonMenuStore.load(menuItems: viewModel.menuItems)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
HomeView()
|
||||
.environment(AccountContext())
|
||||
.environment(PermissionContext())
|
||||
.environment(AppRouter())
|
||||
.environment(RouterPath())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user