// // 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": "直播相册", "travel_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": "提现审核", "cooperation_order": "合作订单", "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 .orderPush(.writeOffList) case "photographer_stats": return .tab(.statistics) case "space_settings": return .destination(.profileSpaceSettings) case "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 "cooperation_order": return .destination(.cooperationOrder) 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 "travel_album": return .destination(.travelAlbumEntry) 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", "travel_album", "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", "cooperation_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 { 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], currentRoleCode: String?) -> HomePermissionRouteAudit { let source: RolePermissionResponse? if let currentRoleCode, !currentRoleCode.isEmpty { source = RolePermissionMatching.match( in: permissions, roleCode: currentRoleCode, roleId: nil, roleName: nil ) } 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, .orderPush, .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() 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: " ") } }