mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-28 21:23:44 +00:00
feat(ng2): rework subscriptions into cards with header actions (#56)
每订阅一卡片:图标 + 名称 + 地址隐藏副题 + 状态徽标三态(已同步/刷新失败/ 尚未刷新)+ 更新/重试 + 「…」菜单(编辑 URL/删除);三列元数据(固定分组/ 服务器/上次成功刷新)+ 失败详情行 + 脱敏说明行;失败卡警告色描边。底部操作 栏移除,添加订阅/更新全部迁入分区头动作槽位(sheet 与错误呈现由壳持有)。 地图 #52 · 票 #56;task build + test 全绿。
This commit is contained in:
@@ -17,6 +17,9 @@ struct MainWindowView: View {
|
||||
let diagnosticReportExporter: any DiagnosticReportExporter
|
||||
|
||||
@State private var selection: NodeID?
|
||||
/// 订阅分区头动作(票 #56):添加订阅 sheet 由壳持有。
|
||||
@State private var showAddSubscription = false
|
||||
@StateObject private var shellActionErrors = ErrorAlertPresenter()
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
@@ -31,6 +34,19 @@ struct MainWindowView: View {
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 920, minHeight: 580)
|
||||
.sheet(isPresented: $showAddSubscription) {
|
||||
AddSubscriptionSheet(workflow: workflow, errors: shellActionErrors)
|
||||
}
|
||||
.alert(
|
||||
"操作失败",
|
||||
isPresented: Binding(
|
||||
get: { shellActionErrors.isPresented },
|
||||
set: { if !$0 { shellActionErrors.dismiss() } })
|
||||
) {
|
||||
Button("好", role: .cancel) {}
|
||||
} message: {
|
||||
Text(shellActionErrors.message ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 分区标题与动作槽位
|
||||
@@ -56,11 +72,25 @@ struct MainWindowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 分区头动作槽位:各分区票落地时把页级动作(添加、保存、导出等)迁入。
|
||||
/// 分区头动作槽位(票 #53/#56):页级动作放在分区大标题右侧。
|
||||
@ViewBuilder
|
||||
private var headerActions: some View {
|
||||
switch route.destination {
|
||||
case .home, .servers, .subscriptions, .settings, .diagnostics:
|
||||
case .subscriptions:
|
||||
HStack(spacing: 8) {
|
||||
if !workflow.refreshingSubscriptionIDs.isEmpty {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Button("添加订阅", systemImage: "plus") {
|
||||
showAddSubscription = true
|
||||
}
|
||||
Button("更新全部", systemImage: "arrow.triangle.2.circlepath") {
|
||||
Task { await workflow.refreshAllSubscriptions() }
|
||||
}
|
||||
.disabled(workflow.subscriptions.isEmpty)
|
||||
}
|
||||
case .home, .servers, .settings, .diagnostics:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 订阅分区(spec #21 D11,issue #35/#41):每订阅一卡片(名称、URL host、
|
||||
/// 状态、上次刷新、服务器数)+ 单个/全部更新 + 编辑 URL + 删除确认;新建即
|
||||
/// 粘贴 URL。卡片数据来自订阅 projection(非敏感);结构化刷新状态在呈现层
|
||||
/// 本地化(story 42)。
|
||||
/// 订阅分区(spec #21 D11,issue #35/#41,地图 #52 票 #56):每订阅一张卡片
|
||||
/// (图标 + 名称 + 「HTTPS 订阅地址已隐藏」+ 状态徽标 + 更新/重试 + 「…」菜单 +
|
||||
/// 三列元数据 + 脱敏说明行),失败卡警告色描边。添加订阅与更新全部在分区头
|
||||
/// 动作槽位(由主窗口壳提供)。卡片数据来自订阅 projection(非敏感);结构化
|
||||
/// 刷新状态在呈现层本地化(story 42)。
|
||||
struct SubscriptionsView: View {
|
||||
@ObservedObject var workflow: CatalogWorkflow
|
||||
let errors: ErrorAlertPresenter
|
||||
/// 删除完成后回调(被删身份集合;主窗口据此清除失效选择)。
|
||||
let onNodesRemoved: (Set<NodeID>) -> Void
|
||||
|
||||
@State private var showAddSheet = false
|
||||
@State private var editTarget: SubscriptionSummary?
|
||||
@State private var deleteTarget: SubscriptionSummary?
|
||||
@State private var isRefreshingAll = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(workflow.subscriptions) { summary in
|
||||
SubscriptionCard(
|
||||
summary: summary,
|
||||
isRefreshing: workflow.refreshingSubscriptionIDs.contains(summary.id),
|
||||
onRefresh: { Task { await workflow.refreshSubscription(summary.id) } },
|
||||
onEdit: { editTarget = summary },
|
||||
onDelete: { deleteTarget = summary })
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
ForEach(workflow.subscriptions) { summary in
|
||||
SubscriptionCard(
|
||||
summary: summary,
|
||||
groupName: workflow.displayName(for: summary.groupID),
|
||||
isRefreshing: workflow.refreshingSubscriptionIDs.contains(summary.id),
|
||||
onRefresh: { Task { await workflow.refreshSubscription(summary.id) } },
|
||||
onEdit: { editTarget = summary },
|
||||
onDelete: { deleteTarget = summary })
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 960, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 28)
|
||||
.padding(.trailing, 32)
|
||||
.padding(.top, 24)
|
||||
.padding(.bottom, 36)
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if workflow.subscriptions.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"暂无订阅", systemImage: "arrow.triangle.2.circlepath",
|
||||
description: Text("用「添加订阅…」粘贴 HTTPS 订阅地址(SIP-008 JSON)")
|
||||
description: Text("用分区头「添加订阅」粘贴 HTTPS 订阅地址(SIP-008 JSON)")
|
||||
)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) { bottomBar }
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
AddSubscriptionSheet(workflow: workflow, errors: errors)
|
||||
}
|
||||
.sheet(item: $editTarget) { summary in
|
||||
EditSubscriptionURLSheet(workflow: workflow, errors: errors, summary: summary)
|
||||
}
|
||||
@@ -59,37 +62,6 @@ struct SubscriptionsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomBar: some View {
|
||||
HStack {
|
||||
Button {
|
||||
showAddSheet = true
|
||||
} label: {
|
||||
Label("添加订阅…", systemImage: "plus")
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
refreshAll()
|
||||
} label: {
|
||||
if isRefreshingAll {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Label("立即更新全部", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
.disabled(workflow.subscriptions.isEmpty || isRefreshingAll)
|
||||
}
|
||||
.padding(12)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private func refreshAll() {
|
||||
isRefreshingAll = true
|
||||
Task {
|
||||
await workflow.refreshAllSubscriptions()
|
||||
isRefreshingAll = false
|
||||
}
|
||||
}
|
||||
|
||||
private func commitDelete() {
|
||||
guard let target = deleteTarget else { return }
|
||||
deleteTarget = nil
|
||||
@@ -109,7 +81,7 @@ extension SubscriptionRefreshStatus {
|
||||
var badgeText: String {
|
||||
switch self {
|
||||
case .never: "尚未刷新"
|
||||
case .succeeded: "正常"
|
||||
case .succeeded: "已同步"
|
||||
case .failed: "刷新失败"
|
||||
}
|
||||
}
|
||||
@@ -133,88 +105,179 @@ extension SubscriptionRefreshStatus {
|
||||
if case .failed = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// 脱敏说明行(票 #56):失败卡说明保留快照语义。
|
||||
var footerNote: String {
|
||||
isFailed
|
||||
? "保留最后一次成功快照 · 不会清空现有节点"
|
||||
: "远端结构由订阅管理,本地仅保留启用状态"
|
||||
}
|
||||
}
|
||||
|
||||
/// 单张订阅卡片。
|
||||
/// 单张订阅卡片(票 #56):头部、三列元数据与脱敏说明;失败态警告色描边。
|
||||
private struct SubscriptionCard: View {
|
||||
let summary: SubscriptionSummary
|
||||
let groupName: String
|
||||
let isRefreshing: Bool
|
||||
let onRefresh: () -> Void
|
||||
let onEdit: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(summary.status.isFailed ? Color.red : Color.secondary)
|
||||
Text(summary.name)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
statusBadge
|
||||
if isRefreshing {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button {
|
||||
onRefresh()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("立即更新此订阅")
|
||||
}
|
||||
}
|
||||
Text(summary.host)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.help("订阅主机(完整地址不入诊断与日志)")
|
||||
HStack(spacing: 12) {
|
||||
Label("\(summary.serverCount) 台服务器", systemImage: "server.rack")
|
||||
Label(summary.status.lastRefreshText, systemImage: "clock")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
.padding(.top, 16)
|
||||
meta
|
||||
.padding(.top, 14)
|
||||
if let detail = summary.status.failureDetail {
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(2)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("编辑 URL…", action: onEdit)
|
||||
.controlSize(.small)
|
||||
Button("删除…", role: .destructive, action: onDelete)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
footer
|
||||
.padding(.top, 14)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
.background.secondary,
|
||||
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(summary.status.isFailed ? Color.orange.opacity(0.55) : Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.quaternary)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBadge: some View {
|
||||
if summary.status.isFailed {
|
||||
Text(summary.status.badgeText)
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(Color.red.opacity(0.15)))
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Text(summary.status.badgeText)
|
||||
.font(.caption2)
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(
|
||||
summary.status.isFailed ? Color.orange.opacity(0.14) : Color.accentColor.opacity(0.12))
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(summary.status.isFailed ? Color.orange : Color.accentColor)
|
||||
}
|
||||
.frame(width: 38, height: 38)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(summary.name)
|
||||
.font(.title3.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text("HTTPS 订阅地址已隐藏")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.help("订阅主机:\(summary.host)(完整地址不入诊断与日志)")
|
||||
}
|
||||
Spacer(minLength: 12)
|
||||
badge
|
||||
if isRefreshing {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.padding(.horizontal, 6)
|
||||
} else {
|
||||
Button(summary.status.isFailed ? "重试" : "更新", action: onRefresh)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.tint)
|
||||
.font(.callout.weight(.medium))
|
||||
}
|
||||
Menu {
|
||||
Button("编辑 URL…", action: onEdit)
|
||||
Button("删除…", role: .destructive, action: onDelete)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 24, height: 24)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
.accessibilityLabel("订阅操作")
|
||||
}
|
||||
}
|
||||
|
||||
private var badge: some View {
|
||||
Text(summary.status.badgeText)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(badgeForeground)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(badgeBackground, in: Capsule())
|
||||
}
|
||||
|
||||
private var badgeForeground: Color {
|
||||
switch summary.status {
|
||||
case .succeeded: .green
|
||||
case .failed: .orange
|
||||
case .never: .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var badgeBackground: Color {
|
||||
switch summary.status {
|
||||
case .succeeded: Color.green.opacity(0.14)
|
||||
case .failed: Color.orange.opacity(0.14)
|
||||
case .never: Color(nsColor: .quaternaryLabelColor).opacity(0.3)
|
||||
}
|
||||
}
|
||||
|
||||
/// 三列元数据(票 #56):固定分组 | 服务器 | 上次成功刷新。
|
||||
private var meta: some View {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
metaColumn("固定分组") {
|
||||
Text(groupName)
|
||||
.lineLimit(1)
|
||||
.help(groupName)
|
||||
}
|
||||
metaColumn("服务器") {
|
||||
Text("\(summary.serverCount) 台")
|
||||
}
|
||||
metaColumn("上次成功刷新") {
|
||||
Text(summary.status.lastRefreshText)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func metaColumn<Content: View>(
|
||||
_ label: String, @ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
content()
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.shield")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(summary.status.footerNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「添加订阅」表单:粘贴 HTTPS 订阅地址;创建后立即首次刷新。
|
||||
private struct AddSubscriptionSheet: View {
|
||||
/// 「添加订阅」表单(分区头入口):粘贴 HTTPS 订阅地址;创建后立即首次刷新。
|
||||
struct AddSubscriptionSheet: View {
|
||||
let workflow: CatalogWorkflow
|
||||
let errors: ErrorAlertPresenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
Reference in New Issue
Block a user