import Foundation extension CatalogTreeNode { var isRootSubscriptionGroup: Bool { isGroup && source == .subscription && parentID == nil } } /// 详情会话的展示值;包含参数明文,不进入目录工作流的非秘密投影或持久快照。 @MainActor struct NodeDetailPresentation { struct Property: Equatable { let label: String let value: String } enum PropertiesID: Hashable { case subscription case information case connection case pluginParameters case connectivity } struct Properties: Equatable, Identifiable { let id: PropertiesID let title: String let items: [Property] } let title: String let subtitle: String let activationMessages: [String] let secondarySubtitle: String? var properties: [Properties] init( node: CatalogTreeNode, server: ServerDetailPresentation?, eligibility: ActivationEligibility?, isActiveTarget: Bool, containsActiveTarget: Bool = false, subscription: SubscriptionSummary? = nil, locale: Locale = .current, timeZone: TimeZone = .current ) { title = node.name let isRootSubscriptionGroup = node.isRootSubscriptionGroup secondarySubtitle = isRootSubscriptionGroup ? String(localized: "配置由远端管理,本地不能修改。", table: "ServerDetails") : nil var descriptions = [ node.isGroup ? (node.isManual ? String(localized: "本地", table: "ServerDetails") : String(localized: "订阅", table: "ServerDetails")) : (node.isManual ? String(localized: "本地配置", table: "ServerDetails") : String(localized: "订阅配置", table: "ServerDetails")) ] if isActiveTarget { descriptions.append(String(localized: "活动目标", table: "ServerDetails")) } else if containsActiveTarget { // 与行级空心闪电同语义:子树内含活动目标,自身不是活动目标。 descriptions.append(String(localized: "含活动目标", table: "ServerDetails")) } subtitle = descriptions.joined(separator: " · ") activationMessages = Self.activationMessages( for: node, server: server, eligibility: eligibility) properties = [] if isRootSubscriptionGroup, let subscriptionProperties = Self.subscriptionInformation( summary: subscription, locale: locale, timeZone: timeZone) { properties.append(subscriptionProperties) } properties.append(Self.information(for: node, eligibility: eligibility)) if let server { properties.append(Self.connection(for: server)) } } private static func activationMessages( for node: CatalogTreeNode, server: ServerDetailPresentation?, eligibility: ActivationEligibility? ) -> [String] { var messages: [String] = [] if node.isGroup { if let reason = eligibility?.ineligibility { switch reason { case .emptyGroup: messages.append(String(localized: "空分组,暂无子节点,不能激活。", table: "ServerDetails")) case .noCandidates: messages.append(String(localized: "分组中没有可激活的有效服务器。", table: "ServerDetails")) } } if let count = eligibility?.skippedInvalidCount, count > 0, eligibility?.canActivate == true { messages.append( String(localized: "激活时将跳过 \(count) 个存在已知阻塞问题的服务器。", table: "ServerDetails")) } } else { messages += node.invalidReasons.map { reason in if case .pluginNotProvided(let program) = reason, let warning = server?.plugin.availabilityWarning(for: program) { return warning } return AppPresentation.message( for: ActivationFailure.invalidLeaf(node: node.id, reason: reason)) } } return messages } private static func subscriptionInformation( summary: SubscriptionSummary?, locale: Locale, timeZone: TimeZone ) -> Properties? { guard let summary else { return nil } var items: [Property] = [] if let date = summary.lastSucceededAt { items.append( Property( label: String(localized: "最后更新时间", table: "ServerDetails"), value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone))) } items.append( Property( label: String(localized: "自动更新", table: "ServerDetails"), value: summary.autoUpdate.effectiveMode.displayName)) if !summary.autoUpdate.allowsUserOverride { items.append( Property( label: String(localized: "配置限制", table: "ServerDetails"), value: String(localized: "供应商禁止覆盖自动更新设置", table: "ServerDetails"))) } if summary.autoUpdate.effectiveMode != .off, let date = summary.autoUpdate.nextUpdateAt { items.append( Property( label: String(localized: "下次更新时间", table: "ServerDetails"), value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone))) } if let used = summary.information?.bytesUsed { items.append( Property( label: String(localized: "已用流量", table: "ServerDetails"), value: SubscriptionInformation.byteCountText(used, locale: locale))) } if let remaining = summary.information?.bytesRemaining { items.append( Property( label: String(localized: "剩余流量", table: "ServerDetails"), value: SubscriptionInformation.byteCountText(remaining, locale: locale))) } if let date = summary.information?.expiresAt { items.append( Property( label: String(localized: "到期时间", table: "ServerDetails"), value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone))) } if let date = summary.information?.trafficResetAt { items.append( Property( label: String(localized: "重置时间", table: "ServerDetails"), value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone))) } return Properties( id: .subscription, title: String(localized: "订阅", table: "ServerDetails"), items: items) } private static func subscriptionTimestamp( _ date: Date, locale: Locale, timeZone: TimeZone ) -> String { date.formatted( Date.FormatStyle(date: .abbreviated, time: .shortened, locale: locale, timeZone: timeZone)) } private static func connection(for server: ServerDetailPresentation) -> Properties { let program: String switch server.plugin.selection { case .none: program = String(localized: "无", table: "ServerDetails") case .named(let name), .unknown(let name): program = name } return Properties( id: .connection, title: String(localized: "连接信息", table: "ServerDetails"), items: [ Property(label: String(localized: "地址", table: "ServerDetails"), value: server.address), Property( label: String(localized: "端口", table: "ServerDetails"), value: String(server.port)), Property( label: String(localized: "加密方式", table: "ServerDetails"), value: server.encryptionMethod), Property(label: String(localized: "插件", table: "ServerDetails"), value: program), ]) } private static func information( for node: CatalogTreeNode, eligibility: ActivationEligibility? ) -> Properties { var information = [Property(label: "ID", value: node.id.rawValue)] if node.isGroup { information += [ Property( label: String(localized: "有效服务器", table: "ServerDetails"), value: String(eligibility?.candidateCount ?? 0)), Property( label: String(localized: "无效服务器", table: "ServerDetails"), value: String(eligibility?.skippedInvalidCount ?? 0)), ] } information += [ Property( label: String(localized: "创建时间", table: "ServerDetails"), value: Self.timestamp(node.createdAt)), Property( label: String(localized: "修改时间", table: "ServerDetails"), value: Self.timestamp(node.updatedAt)), ] return Properties( id: .information, title: String(localized: "信息", table: "ServerDetails"), items: information) } mutating func appendPluginParameters(_ raw: String) { guard !raw.isEmpty else { return } let items: [Property] if let parsed = PluginOptionsDraft.parse(raw) { items = parsed.items.map { item in Property( label: item.key, value: item.hasValue ? (item.value.isEmpty ? String(localized: "空值", table: "ServerDetails") : item.value) : String(localized: "开关", table: "ServerDetails")) } } else { items = [Property(label: String(localized: "原始文本", table: "ServerDetails"), value: raw)] } properties.append( Properties( id: .pluginParameters, title: String(localized: "插件参数", table: "ServerDetails"), items: items)) } mutating func appendPluginParameterFailure() { properties.append( Properties( id: .pluginParameters, title: String(localized: "插件参数", table: "ServerDetails"), items: [ Property( label: String(localized: "插件参数", table: "ServerDetails"), value: String(localized: "无法读取插件参数", table: "ServerDetails")) ])) } private static func timestamp(_ date: Date?) -> String { guard let date else { return "—" } return date.formatted(date: .numeric, time: .shortened) } } extension NodeDetailPresentation { static func connectivityHistory( _ records: [ServerConnectivity.Record], locale: Locale, timeZone: TimeZone ) -> Properties { Properties( id: .connectivity, title: ConnectivityCopy.text("连通性测试"), items: records.isEmpty ? [Property(label: ConnectivityCopy.text("状态"), value: ConnectivityCopy.text("尚未测试"))] : records.map { record in Property( label: connectivityTime(record.finishedAt, locale: locale, timeZone: timeZone), value: ConnectivityCopy.outcome(record.outcome)) }) } static func connectivitySummary( _ summary: ServerConnectivity.Summary, serverName: (NodeID) -> String, locale: Locale, timeZone: TimeZone ) -> Properties { let items: [Property] if let id = summary.serverID, let record = summary.record { items = [ Property( label: ConnectivityCopy.text("响应延迟"), value: ConnectivityCopy.outcome(record.outcome)), Property(label: ConnectivityCopy.text("服务器"), value: serverName(id)), Property( label: ConnectivityCopy.text("测试时间"), value: connectivityTime(record.finishedAt, locale: locale, timeZone: timeZone)), ] } else { items = [ Property( label: ConnectivityCopy.text("状态"), value: ConnectivityCopy.text(summary.hasResults ? "无成功结果" : "尚未测试")) ] } return Properties(id: .connectivity, title: ConnectivityCopy.text("最快响应延迟"), items: items) } private static func connectivityTime(_ date: Date, locale: Locale, timeZone: TimeZone) -> String { date.formatted( Date.FormatStyle(date: .omitted, time: .standard, locale: locale, timeZone: timeZone)) } }