From 9ccee1fc5398e375c7c26e9cc8e44734563ae36b Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Tue, 27 Oct 2026 01:19:35 +0800 Subject: [PATCH] refactor(servers): simplify latency details --- .../Presentation/NodeDetailPresentation.swift | 46 +++++ .../App/Resources/Connectivity.xcstrings | 51 +++++ .../UI/Servers/CatalogNodeDetailView.swift | 189 +++++++++++------- .../App/UI/Servers/ConnectivityViews.swift | 38 ---- .../App/UI/Servers/NodeDetailView.swift | 5 +- .../Servers/NodeDetailPresentationTests.swift | 83 +++++++- 6 files changed, 295 insertions(+), 117 deletions(-) diff --git a/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift b/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift index ee44bb8c..26ceb4a7 100644 --- a/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift +++ b/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift @@ -19,6 +19,7 @@ struct NodeDetailPresentation { case information case connection case pluginParameters + case connectivity } struct Properties: Equatable, Identifiable { @@ -249,3 +250,48 @@ struct NodeDetailPresentation { 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)) + } +} diff --git a/ShadowsocksX-NG2/App/Resources/Connectivity.xcstrings b/ShadowsocksX-NG2/App/Resources/Connectivity.xcstrings index cc2c35ee..a1c962ec 100644 --- a/ShadowsocksX-NG2/App/Resources/Connectivity.xcstrings +++ b/ShadowsocksX-NG2/App/Resources/Connectivity.xcstrings @@ -357,6 +357,57 @@ } } } + }, + "状态": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Status" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "状态" + } + } + } + }, + "服务器": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "服务器" + } + } + } + }, + "测试时间": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Test time" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测试时间" + } + } + } } }, "version": "1.0" diff --git a/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift b/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift index 9f515a39..21cb1632 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift @@ -18,82 +18,15 @@ struct CatalogNodeDetailView: View { var body: some View { if let node { - let detail = presentation(for: node) - NodeDetailView(detail: detail) { - Button( - activation.pendingTargetID == nodeID - ? String(localized: "激活中…", table: "ServerDetails") - : String(localized: "激活", table: "ServerDetails"), - systemImage: "bolt.fill" - ) { - Task { @MainActor in - do { - _ = try await activation.activate(nodeID, via: workflow) - } catch { - // 激活状态记录失败,详情在 feedback 区域显示结果。 - } - } - } - .disabled(!(eligibility?.canActivate ?? false) || activation.isPending) - if let subscription = subscriptionSummary(for: node) { - Button( - workflow.refreshingSubscriptionIDs.contains(subscription.id) - ? String(localized: "更新中…", table: "ServerDetails") - : String(localized: "更新", table: "ServerDetails"), - systemImage: "arrow.clockwise" - ) { - Task { await workflow.refreshSubscription(subscription.id) } - } - .disabled(workflow.refreshingSubscriptionIDs.contains(subscription.id)) - } - } secondaryActions: { + Group { if let module = workflow.connectivity { - ConnectivityTestButton(module: module, node: node) - } - Button( - workflow.isFavorite(nodeID) - ? String(localized: "取消收藏", table: "ServerDetails") - : String(localized: "收藏", table: "ServerDetails") - ) { - do { - try workflow.setFavorite(nodeID, isFavorite: !workflow.isFavorite(nodeID)) - } catch { - errors.present(error) + ObservedConnectivityProperties( + module: module, node: node, serverName: workflow.displayName(for:) + ) { properties in + detailView(for: node, connectivity: properties) } - } - if !node.isGroup && parameters.failure != nil { - Button(String(localized: "重新加载", table: "ServerDetails")) { - parameters.reload(load: workflow.serverDetailPluginOptions) - } - } - } feedback: { - ActivationDetailFeedbackView( - messages: detail.activationMessages, - completion: activation.completion?.targetID == nodeID ? activation.completion : nil) - if let subscription = subscriptionSummary(for: node) { - SubscriptionRefreshFeedbackView( - isRefreshing: workflow.refreshingSubscriptionIDs.contains(subscription.id), - completion: workflow.subscriptionRefreshCompletions[subscription.id] - ) - .id(subscription.id) - } - } propertiesFooter: { group in - if group.id == .subscription, - let fraction = subscriptionSummary(for: node)?.information?.usedFraction - { - ProgressView(value: fraction) { - Text("流量消耗") - } currentValueLabel: { - Text(fraction, format: .percent.precision(.fractionLength(0))) - } - .progressViewStyle(.linear) - .tint(.accentColor) } else { - nil - } - } supplement: { - if let module = workflow.connectivity { - ConnectivityDetailView(module: module, node: node, workflow: workflow) + detailView(for: node) } } .onAppear { @@ -107,6 +40,91 @@ struct CatalogNodeDetailView: View { } } + private func detailView( + for node: CatalogTreeNode, connectivity: NodeDetailPresentation.Properties? = nil + ) -> some View { + var detail = presentation(for: node) + if let connectivity { detail.properties.append(connectivity) } + return NodeDetailView(detail: detail) { + primaryActions(for: node) + } secondaryActions: { + if let module = workflow.connectivity { + ConnectivityTestButton(module: module, node: node) + } + Button( + workflow.isFavorite(nodeID) + ? String(localized: "取消收藏", table: "ServerDetails") + : String(localized: "收藏", table: "ServerDetails") + ) { + do { + try workflow.setFavorite(nodeID, isFavorite: !workflow.isFavorite(nodeID)) + } catch { + errors.present(error) + } + } + if !node.isGroup && parameters.failure != nil { + Button(String(localized: "重新加载", table: "ServerDetails")) { + parameters.reload(load: workflow.serverDetailPluginOptions) + } + } + } feedback: { + ActivationDetailFeedbackView( + messages: detail.activationMessages, + completion: activation.completion?.targetID == nodeID ? activation.completion : nil) + if let subscription = subscriptionSummary(for: node) { + SubscriptionRefreshFeedbackView( + isRefreshing: workflow.refreshingSubscriptionIDs.contains(subscription.id), + completion: workflow.subscriptionRefreshCompletions[subscription.id] + ) + .id(subscription.id) + } + } propertiesFooter: { group in + if group.id == .subscription, + let fraction = subscriptionSummary(for: node)?.information?.usedFraction + { + ProgressView(value: fraction) { + Text("流量消耗") + } currentValueLabel: { + Text(fraction, format: .percent.precision(.fractionLength(0))) + } + .progressViewStyle(.linear) + .tint(.accentColor) + } else { + nil + } + } + } + + @ViewBuilder + private func primaryActions(for node: CatalogTreeNode) -> some View { + Button( + activation.pendingTargetID == nodeID + ? String(localized: "激活中…", table: "ServerDetails") + : String(localized: "激活", table: "ServerDetails"), + systemImage: "bolt.fill" + ) { + Task { @MainActor in + do { + _ = try await activation.activate(nodeID, via: workflow) + } catch { + // 激活状态记录失败,详情在 feedback 区域显示结果。 + } + } + } + .disabled(!(eligibility?.canActivate ?? false) || activation.isPending) + if let subscription = subscriptionSummary(for: node) { + Button( + workflow.refreshingSubscriptionIDs.contains(subscription.id) + ? String(localized: "更新中…", table: "ServerDetails") + : String(localized: "更新", table: "ServerDetails"), + systemImage: "arrow.clockwise" + ) { + Task { await workflow.refreshSubscription(subscription.id) } + } + .disabled(workflow.refreshingSubscriptionIDs.contains(subscription.id)) + } + } + private func presentation(for node: CatalogTreeNode) -> NodeDetailPresentation { let server = node.isGroup ? nil : workflow.serverDetailPresentation(for: nodeID) let subscription = subscriptionSummary(for: node) @@ -129,3 +147,28 @@ struct CatalogNodeDetailView: View { return workflow.subscriptions.first { $0.groupID == node.id } } } + +/// 只观察测试资料并生成属性;布局由通用节点详情负责。 +private struct ObservedConnectivityProperties: View { + @Environment(\.locale) private var locale + @Environment(\.timeZone) private var timeZone + @ObservedObject var module: ServerConnectivity + let node: CatalogTreeNode + let serverName: (NodeID) -> String + @ViewBuilder let content: (NodeDetailPresentation.Properties) -> Content + + var body: some View { + if node.isGroup { + TimelineView(.periodic(from: .now, by: 1)) { context in + content( + NodeDetailPresentation.connectivitySummary( + module.summary(for: node.connectivityServerIDs, at: context.date), + serverName: serverName, locale: locale, timeZone: timeZone)) + } + } else { + content( + NodeDetailPresentation.connectivityHistory( + module.history(for: node.id), locale: locale, timeZone: timeZone)) + } + } +} diff --git a/ShadowsocksX-NG2/App/UI/Servers/ConnectivityViews.swift b/ShadowsocksX-NG2/App/UI/Servers/ConnectivityViews.swift index 26fb57a0..86037baf 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/ConnectivityViews.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/ConnectivityViews.swift @@ -97,41 +97,3 @@ enum ConnectivityCopy { } } } - -struct ConnectivityDetailView: View { - @ObservedObject var module: ServerConnectivity - let node: CatalogTreeNode - let workflow: CatalogWorkflow - - var body: some View { - TimelineView(.periodic(from: .now, by: 1)) { context in - VStack(alignment: .leading, spacing: 8) { - Text(ConnectivityCopy.text(node.isGroup ? "最快响应延迟" : "连通性测试")) - .font(.headline) - if node.isGroup { - ConnectivityCell(module: module, node: node, workflow: workflow) - let summary = module.summary(for: node.connectivityServerIDs, at: context.date) - if let id = summary.serverID, let record = summary.record { - Text(workflow.displayName(for: id)) - Text(timestamp(record, at: context.date)).foregroundStyle(.secondary) - } else if summary.hasResults { - Text(ConnectivityCopy.text("无成功结果")).foregroundStyle(.secondary) - } - } else { - ForEach(module.history(for: node.id)) { record in - HStack { - Text(timestamp(record, at: context.date)).foregroundStyle(.secondary) - Spacer() - Text(ConnectivityCopy.outcome(record.outcome)) - } - } - } - } - } - } - - private func timestamp(_ record: ServerConnectivity.Record, at date: Date) -> String { - record.finishedAt.formatted(date: .numeric, time: .standard) - + (record.isExpired(at: date) ? " · " + ConnectivityCopy.text("已过期") : "") - } -} diff --git a/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift b/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift index 5b7d7d93..dc50487f 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift @@ -2,15 +2,13 @@ import SwiftUI /// 只接受展示值与外部动作;不查询节点类型、工作流或凭据。 struct NodeDetailView< - PrimaryActions: View, SecondaryActions: View, Feedback: View, PropertiesFooter: View, - Supplement: View + PrimaryActions: View, SecondaryActions: View, Feedback: View, PropertiesFooter: View >: View { let detail: NodeDetailPresentation @ViewBuilder let primaryActions: PrimaryActions @ViewBuilder let secondaryActions: SecondaryActions @ViewBuilder let feedback: Feedback let propertiesFooter: (NodeDetailPresentation.Properties) -> PropertiesFooter? - @ViewBuilder let supplement: Supplement var body: some View { ScrollView { @@ -69,7 +67,6 @@ struct NodeDetailView< } } } - supplement } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) diff --git a/ShadowsocksX-NG2/Tests/Servers/NodeDetailPresentationTests.swift b/ShadowsocksX-NG2/Tests/Servers/NodeDetailPresentationTests.swift index 0d386f24..ec354640 100644 --- a/ShadowsocksX-NG2/Tests/Servers/NodeDetailPresentationTests.swift +++ b/ShadowsocksX-NG2/Tests/Servers/NodeDetailPresentationTests.swift @@ -107,14 +107,93 @@ struct NodeDetailPresentationTests { EmptyView() } propertiesFooter: { group in group.id == .information ? FooterMarker() : nil - } supplement: { - EmptyView() }) host.frame = NSRect(x: 0, y: 0, width: 480, height: 600) host.layoutSubtreeIfNeeded() #expect(containsFooter(in: host)) } + @Test + func connectivityHistoryUsesTimeOnlyAndPreservesRepeatedLabelsAndFailures() { + let date = Date(timeIntervalSince1970: 37_421) + let records = [ + ServerConnectivity.Record( + finishedAt: date, outcome: .success(milliseconds: 42), networkExpired: true), + ServerConnectivity.Record(finishedAt: date, outcome: .requestTimeout), + ServerConnectivity.Record(finishedAt: date.addingTimeInterval(-60), outcome: .http(302)), + ] + let properties = NodeDetailPresentation.connectivityHistory( + records, locale: Locale(identifier: "en_GB"), timeZone: TimeZone(secondsFromGMT: 3600)!) + #expect(properties.id == .connectivity) + #expect(properties.items.map(\.label) == ["11:23:41", "11:23:41", "11:22:41"]) + #expect(properties.items.map(\.value) == ["42 ms", ConnectivityCopy.text("请求超时"), "HTTP 302"]) + } + + @Test + func connectivityEmptyStatesAreExplicitProperties() { + let locale = Locale(identifier: "en_GB") + let zone = TimeZone(secondsFromGMT: 0)! + let history = NodeDetailPresentation.connectivityHistory([], locale: locale, timeZone: zone) + #expect( + history.items == [ + .init(label: ConnectivityCopy.text("状态"), value: ConnectivityCopy.text("尚未测试")) + ]) + for hasResults in [false, true] { + let summary = ServerConnectivity.Summary( + serverID: nil, record: nil, hasResults: hasResults, isTesting: true) + let properties = NodeDetailPresentation.connectivitySummary( + summary, + serverName: { _ in + Issue.record("No server should be resolved") + return "" + }, + locale: locale, timeZone: zone) + #expect( + properties.items == [ + .init( + label: ConnectivityCopy.text("状态"), + value: ConnectivityCopy.text(hasResults ? "无成功结果" : "尚未测试")) + ]) + } + } + + @Test + func connectivityGroupPropertiesFollowFreshnessNetworkChangesAndNames() async { + let ids = [NodeID.fresh(), NodeID.fresh()] + var now = Date(timeIntervalSince1970: 37_421) + var latency = 10 + var name = "First" + let module = ServerConnectivity( + configuration: { _ in + SslocalServerDocument( + id: "test", server: "example.com", serverPort: 443, password: "secret", + method: "aes-256-gcm", plugin: nil, pluginOpts: nil) + }, now: { now }, + measure: { _, _, _ in .success(milliseconds: latency) }) + func properties(at date: Date) -> NodeDetailPresentation.Properties { + NodeDetailPresentation.connectivitySummary( + module.summary(for: ids, at: date), serverName: { $0 == ids[0] ? name : "Second" }, + locale: Locale(identifier: "en_GB"), timeZone: TimeZone(secondsFromGMT: 0)!) + } + await module.wait(for: module.test([ids[0]])) + #expect(properties(at: now).items.map(\.value) == ["10 ms", "First", "10:23:41"]) + name = "Renamed" + #expect(properties(at: now).items[1].value == "Renamed") + now.addTimeInterval(60) + latency = 20 + await module.wait(for: module.test([ids[1]])) + #expect(properties(at: now).items[1].value == "Renamed") + // First expires while Second is still fresh, without another measurement. + #expect( + properties(at: now.addingTimeInterval(240)).items.map(\.value) == [ + "20 ms", "Second", "10:24:41", + ]) + module.networkChanged() + #expect(properties(at: now.addingTimeInterval(240)).items[1].value == "Renamed") + module.setURL(URL(string: "http://example.com/check")!) + #expect(properties(at: now).items.map(\.value) == [ConnectivityCopy.text("尚未测试")]) + } + private func containsFooter(in view: NSView) -> Bool { view.identifier?.rawValue == "properties-footer" || view.subviews.contains { containsFooter(in: $0) }