feat: show subscription usage in detail properties footer

This commit is contained in:
Qiu Yuzhou
2026-10-16 01:01:56 +08:00
parent d123497fe4
commit 1be0976bc6
4 changed files with 82 additions and 9 deletions
@@ -14,7 +14,15 @@ struct NodeDetailPresentation {
let value: String
}
struct Properties: Equatable {
enum PropertiesID: Hashable {
case subscription
case information
case connection
case pluginParameters
}
struct Properties: Equatable, Identifiable {
let id: PropertiesID
let title: String
let items: [Property]
}
@@ -121,7 +129,8 @@ struct NodeDetailPresentation {
value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone)))
}
guard !items.isEmpty else { return nil }
return Properties(title: String(localized: "订阅", table: "ServerDetails"), items: items)
return Properties(
id: .subscription, title: String(localized: "订阅", table: "ServerDetails"), items: items)
}
private static func subscriptionTimestamp(
@@ -138,7 +147,7 @@ struct NodeDetailPresentation {
case .named(let name), .unknown(let name): program = name
}
return Properties(
title: String(localized: "连接信息", table: "ServerDetails"),
id: .connection, title: String(localized: "连接信息", table: "ServerDetails"),
items: [
Property(label: String(localized: "地址", table: "ServerDetails"), value: server.address),
Property(
@@ -171,7 +180,8 @@ struct NodeDetailPresentation {
label: String(localized: "修改时间", table: "ServerDetails"),
value: Self.timestamp(node.updatedAt)),
]
return Properties(title: String(localized: "信息", table: "ServerDetails"), items: information)
return Properties(
id: .information, title: String(localized: "信息", table: "ServerDetails"), items: information)
}
mutating func appendPluginParameters(_ raw: String) {
@@ -189,13 +199,15 @@ struct NodeDetailPresentation {
items = [Property(label: String(localized: "原始文本", table: "ServerDetails"), value: raw)]
}
properties.append(
Properties(title: String(localized: "插件参数", table: "ServerDetails"), items: items))
Properties(
id: .pluginParameters, title: String(localized: "插件参数", table: "ServerDetails"),
items: items))
}
mutating func appendPluginParameterFailure() {
properties.append(
Properties(
title: String(localized: "插件参数", table: "ServerDetails"),
id: .pluginParameters, title: String(localized: "插件参数", table: "ServerDetails"),
items: [
Property(
label: String(localized: "插件参数", table: "ServerDetails"),
@@ -75,6 +75,20 @@ struct CatalogNodeDetailView: View {
)
.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)
@@ -2,12 +2,14 @@ import SwiftUI
/// 只接受展示值与外部动作;不查询节点类型、工作流或凭据。
struct NodeDetailView<
PrimaryActions: View, SecondaryActions: View, Feedback: View, Supplement: View
PrimaryActions: View, SecondaryActions: View, Feedback: View, PropertiesFooter: View,
Supplement: 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 {
@@ -45,8 +47,9 @@ struct NodeDetailView<
feedback
}
Divider()
ForEach(Array(detail.properties.enumerated()), id: \.offset) { _, group in
if !group.items.isEmpty {
ForEach(detail.properties) { group in
let footer = propertiesFooter(group)
if !group.items.isEmpty || footer != nil {
VStack(alignment: .leading, spacing: 10) {
Text(group.title).font(.headline)
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
@@ -62,6 +65,7 @@ struct NodeDetailView<
}
}
}
footer
}
}
}
@@ -1,4 +1,6 @@
import AppKit
import Foundation
import SwiftUI
import Testing
@testable import ShadowsocksX_NG2
@@ -75,6 +77,47 @@ struct NodeDetailPresentationTests {
#expect(detail.activationMessages == ["插件文件不可执行,请检查文件类型和执行权限。"])
}
@Test(arguments: [false, true])
func propertiesFooterRendersWithAndWithoutRows(hasRows: Bool) throws {
var detail = NodeDetailPresentation(
node: node(isGroup: true), server: nil, eligibility: nil, isActiveTarget: false)
detail.properties = [
.init(
id: .information, title: "Properties", items: hasRows ? [.init(label: "A", value: "B")] : []
)
]
let host = NSHostingView(
rootView: NodeDetailView(detail: detail) {
EmptyView()
} secondaryActions: {
EmptyView()
} feedback: {
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))
}
private func containsFooter(in view: NSView) -> Bool {
view.identifier?.rawValue == "properties-footer"
|| view.subviews.contains { containsFooter(in: $0) }
}
private struct FooterMarker: NSViewRepresentable {
func makeNSView(context: Context) -> NSTextField {
let field = NSTextField(labelWithString: "Custom footer")
field.identifier = NSUserInterfaceItemIdentifier("properties-footer")
return field
}
func updateNSView(_ nsView: NSTextField, context: Context) {}
}
private func node(isGroup: Bool, invalidReasons: [LeafInvalidationReason] = []) -> CatalogTreeNode
{
CatalogTreeNode(