feat(servers): show subscription details in server view

This commit is contained in:
Qiu Yuzhou
2026-10-14 17:39:44 +08:00
parent 287cc5d5d5
commit ea66ead10b
4 changed files with 156 additions and 7 deletions
@@ -15,13 +15,21 @@ struct NodeDetailPresentation {
let title: String
var subtitle: String
let secondarySubtitle: String?
var properties: [Properties]
init(
node: CatalogTreeNode, server: ServerDetailPresentation?,
eligibility: ActivationEligibility?, isActiveTarget: Bool
eligibility: ActivationEligibility?, isActiveTarget: Bool,
subscription: SubscriptionSummary? = nil,
locale: Locale = .current, timeZone: TimeZone = .current
) {
title = node.name
let isRootSubscriptionGroup = Self.isRootSubscriptionGroup(node)
secondarySubtitle =
isRootSubscriptionGroup
? String(localized: "配置由远端管理,本地不能修改。", table: "ServerDetails")
: nil
var descriptions = [
node.isGroup
? (node.isManual
@@ -57,10 +65,67 @@ struct NodeDetailPresentation {
}
}
subtitle = descriptions.joined(separator: " · ")
properties = [Self.information(for: node, 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 isRootSubscriptionGroup(_ node: CatalogTreeNode) -> Bool {
node.isGroup && node.source == .subscription && node.parentID == nil
}
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)))
}
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)))
}
guard !items.isEmpty else { return nil }
return Properties(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 {
@@ -341,6 +341,73 @@
}
}
}
},
"配置由远端管理,本地不能修改。": {
"extractionState": "manual",
"comment": "Second subtitle line for the root group of a subscription.",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Managed remotely; cannot be modified locally."
}
}
}
},
"最后更新时间": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Last Update"
}
}
}
},
"已用流量": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Used Data"
}
}
}
},
"剩余流量": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Remaining Data"
}
}
}
},
"到期时间": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Expiration"
}
}
}
},
"重置时间": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Traffic Reset"
}
}
}
}
},
"version": "1.0"
@@ -2,6 +2,8 @@ import SwiftUI
/// 装配当前节点的详情会话;查询、参数装载与命令均留在共用视图之外。
struct CatalogNodeDetailView: View {
@Environment(\.locale) private var locale
@Environment(\.timeZone) private var timeZone
@ObservedObject var workflow: CatalogWorkflow
@ObservedObject var activation: ActivationFeedbackState
let nodeID: NodeID
@@ -67,8 +69,10 @@ struct CatalogNodeDetailView: View {
private func presentation(for node: CatalogTreeNode) -> NodeDetailPresentation {
let server = node.isGroup ? nil : workflow.serverDetailPresentation(for: nodeID)
let subscription = subscriptionSummary(for: node)
var detail = NodeDetailPresentation(
node: node, server: server, eligibility: eligibility, isActiveTarget: isActiveTarget)
node: node, server: server, eligibility: eligibility, isActiveTarget: isActiveTarget,
subscription: subscription, locale: locale, timeZone: timeZone)
if let server, server.plugin.selection != .none {
if parameters.failure != nil {
detail.appendPluginParameterFailure()
@@ -89,4 +93,9 @@ struct CatalogNodeDetailView: View {
}
return detail
}
private func subscriptionSummary(for node: CatalogTreeNode) -> SubscriptionSummary? {
guard node.isGroup, node.source == .subscription, node.parentID == nil else { return nil }
return workflow.subscriptions.first { $0.groupID == node.id }
}
}
@@ -13,10 +13,18 @@ struct NodeDetailView<Actions: View, Supplement: View>: View {
Text(detail.title)
.font(.title2.weight(.semibold))
.textSelection(.enabled)
Text(detail.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.textSelection(.enabled)
VStack(alignment: .leading, spacing: 4) {
Text(detail.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.textSelection(.enabled)
if let secondarySubtitle = detail.secondarySubtitle {
Text(secondarySubtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
HStack(spacing: 16) { actions }
.buttonStyle(.link)
}