Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/MainWindowView.swift
T
Qiu Yuzhou f556c5fdb6 feat(ng2): rework subscriptions into cards with header actions (#56)
每订阅一卡片:图标 + 名称 + 地址隐藏副题 + 状态徽标三态(已同步/刷新失败/
尚未刷新)+ 更新/重试 + 「…」菜单(编辑 URL/删除);三列元数据(固定分组/
服务器/上次成功刷新)+ 失败详情行 + 脱敏说明行;失败卡警告色描边。底部操作
栏移除,添加订阅/更新全部迁入分区头动作槽位(sheet 与错误呈现由壳持有)。

地图 #52 · 票 #56;task build + test 全绿。
2026-09-24 19:34:03 +08:00

407 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// 主窗口外壳(地图 #52,票 #53):NavigationSplitView 侧栏承载五项导航与
/// 底部常驻代理状态卡;详情区按 route destination 承载既有五个分区视图,
/// 顶部是系统设置风格的分区大标题(动作槽位留给各分区票填充)。路由状态
/// 仍由 WorkspaceRoute 持有;代理状态卡的开关、模式与摘要只来自代理控制
/// 工作流的整体 snapshot(issue #47),与状态菜单同一口径。
struct MainWindowView: View {
@ObservedObject var route: WorkspaceRoute
@ObservedObject var workflow: CatalogWorkflow
@ObservedObject var control: ProxyControlWorkflow
@ObservedObject var proxyController: ProxyRuntimeController
@ObservedObject var diagnostics: DiagnosticsWorkflow
@ObservedObject var settingsWorkflow: SettingsWorkflow
@ObservedObject var loginController: LaunchAtLoginController
let clipboard: any TextClipboard
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 {
sidebar
.navigationSplitViewColumnWidth(min: 200, ideal: 236, max: 300)
} detail: {
VStack(spacing: 0) {
destinationHeader(route.destination.label) {
headerActions
}
destinationView
}
}
.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: - 分区标题与动作槽位
@ViewBuilder
private func destinationHeader<ActionContent: View>(
_ title: String,
@ViewBuilder actions: () -> ActionContent
) -> some View {
VStack(spacing: 0) {
HStack(alignment: .center) {
Text(title)
.font(.system(size: 28, weight: .bold))
.lineLimit(1)
Spacer(minLength: 16)
actions()
}
.padding(.leading, 28)
.padding(.trailing, 32)
.padding(.top, 18)
.padding(.bottom, 14)
Divider()
}
}
/// 分区头动作槽位(票 #53/#56):页级动作放在分区大标题右侧。
@ViewBuilder
private var headerActions: some View {
switch route.destination {
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()
}
}
// MARK: - 分区承载
@ViewBuilder
private var destinationView: some View {
switch route.destination {
case .home:
WorkspaceHomeView(
workflow: workflow,
control: control,
clipboard: clipboard,
onManageServers: { route.navigate(to: .servers) })
case .servers:
ServersView(
workflow: workflow,
proxyController: proxyController,
selection: $selection,
clipboard: clipboard)
case .subscriptions:
WorkspaceSubscriptionsView(
workflow: workflow,
onNodesRemoved: clearSelectionIfInvalidated)
case .settings:
SettingsView(workflow: settingsWorkflow, loginController: loginController)
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .diagnostics:
WorkspaceDiagnosticsView(
diagnostics: diagnostics,
clipboard: clipboard,
reportExporter: diagnosticReportExporter)
}
}
private func clearSelectionIfInvalidated(_ removed: Set<NodeID>) {
if let selection, removed.contains(selection) {
self.selection = nil
}
}
}
// MARK: - 分区包装(票 #53)
private struct WorkspaceHomeView: View {
@ObservedObject var workflow: CatalogWorkflow
@ObservedObject var control: ProxyControlWorkflow
let clipboard: any TextClipboard
let onManageServers: () -> Void
@StateObject private var errors = ErrorAlertPresenter()
@State private var showLegacyImportSheet = false
@State private var didOfferLegacyImport = false
var body: some View {
HomeView(
workflow: workflow,
control: control,
clipboard: clipboard,
onManageServers: onManageServers,
errors: errors
)
.sheet(isPresented: $showLegacyImportSheet) {
LegacyImportSheet(workflow: workflow)
}
.onAppear {
guard !didOfferLegacyImport, workflow.legacyImportState.shouldOffer else { return }
didOfferLegacyImport = true
showLegacyImportSheet = true
}
.alert(
"操作失败",
isPresented: Binding(
get: { errors.isPresented },
set: { if !$0 { errors.dismiss() } })
) {
Button("好", role: .cancel) {}
} message: {
Text(errors.message ?? "")
}
}
}
private struct WorkspaceSubscriptionsView: View {
@ObservedObject var workflow: CatalogWorkflow
let onNodesRemoved: (Set<NodeID>) -> Void
@StateObject private var errors = ErrorAlertPresenter()
var body: some View {
SubscriptionsView(
workflow: workflow,
errors: errors,
onNodesRemoved: onNodesRemoved
)
.alert(
"操作失败",
isPresented: Binding(
get: { errors.isPresented },
set: { if !$0 { errors.dismiss() } })
) {
Button("好", role: .cancel) {}
} message: {
Text(errors.message ?? "")
}
}
}
private struct WorkspaceDiagnosticsView: View {
@ObservedObject var diagnostics: DiagnosticsWorkflow
let clipboard: any TextClipboard
let reportExporter: any DiagnosticReportExporter
@StateObject private var errors = ErrorAlertPresenter()
var body: some View {
HStack(spacing: 0) {
DiagnosticsSummarySidebar(workflow: diagnostics)
.frame(width: 220)
Divider()
DiagnosticsView(
diagnostics: diagnostics,
errors: errors,
clipboard: clipboard,
reportExporter: reportExporter
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.alert(
"操作失败",
isPresented: Binding(
get: { errors.isPresented },
set: { if !$0 { errors.dismiss() } })
) {
Button("好", role: .cancel) {}
} message: {
Text(errors.message ?? "")
}
}
}
}
extension WorkspaceDestination {
var label: String {
switch self {
case .home: "首页"
case .servers: "服务器"
case .subscriptions: "订阅"
case .settings: "设置"
case .diagnostics: "诊断"
}
}
var systemImage: String {
switch self {
case .home: "house"
case .servers: "server.rack"
case .subscriptions: "arrow.triangle.2.circlepath"
case .settings: "gearshape"
case .diagnostics: "chart.bar"
}
}
}
// MARK: - 侧栏与底部代理状态卡(同文件扩展,保持 private 访问)
extension MainWindowView {
// MARK: - 侧栏
private var sidebar: some View {
List(selection: navigationBinding) {
Section {
ForEach(WorkspaceDestination.allCases) { destination in
sidebarRow(destination)
.tag(destination)
}
}
}
.listStyle(.sidebar)
.safeAreaInset(edge: .top, spacing: 0) { identityHeader }
.safeAreaInset(edge: .bottom, spacing: 0) { statusCard }
}
private var identityHeader: some View {
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.primary)
Image(systemName: "network")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Color(nsColor: .windowBackgroundColor))
}
.frame(width: 30, height: 30)
Text("ShadowsocksX-NG")
.font(.headline)
Spacer(minLength: 0)
}
.padding(.leading, 16)
.padding(.trailing, 12)
.padding(.top, 12)
.padding(.bottom, 4)
}
private func sidebarRow(_ destination: WorkspaceDestination) -> some View {
HStack(spacing: 8) {
Label(destination.label, systemImage: destination.systemImage)
Spacer(minLength: 0)
if let count = badgeCount(for: destination) {
Text("\(count)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(Capsule().fill(.quaternary))
}
}
}
private var navigationBinding: Binding<WorkspaceDestination?> {
Binding(
get: { route.destination },
set: { destination in
guard let destination else { return }
route.navigate(to: destination)
})
}
/// 行尾数量角标:服务器 = 目录树内服务器叶子数;订阅 = 订阅数。
private func badgeCount(for destination: WorkspaceDestination) -> Int? {
switch destination {
case .servers:
serverLeafCount > 0 ? serverLeafCount : nil
case .subscriptions:
workflow.subscriptions.isEmpty ? nil : workflow.subscriptions.count
default:
nil
}
}
private var serverLeafCount: Int {
func leaves(_ nodes: [CatalogTreeNode]) -> Int {
nodes.reduce(0) { $0 + ($1.isGroup ? leaves($1.childNodes) : 1) }
}
return leaves(workflow.tree.roots)
}
// MARK: - 底部代理状态卡
private var statusCard: some View {
let summary = StatusMenuModel.summary(from: control.snapshot)
return VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 8) {
Toggle("代理开关", isOn: proxyToggleBinding)
.toggleStyle(.switch)
.controlSize(.mini)
.labelsHidden()
Text(summary.status)
.font(.footnote.weight(.semibold))
.foregroundStyle(statusColor(summary))
.lineLimit(1)
}
Text(summary.targetPath ?? "未激活")
.font(.footnote.weight(.medium))
.lineLimit(1)
.truncationMode(.middle)
.help(
summary.targetPath.map { "活动目标:\($0)" }
?? "未设置活动目标;在首页或服务器目录中激活")
Text("模式:\(summary.modeLabel)")
.font(.caption)
.foregroundStyle(.secondary)
if let detail = summary.detail {
Text(detail)
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(2)
.help(detail)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(.quaternary)
)
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 12)
}
private var proxyToggleBinding: Binding<Bool> {
Binding(
get: { control.snapshot.runtime.isOn },
set: { enabled in
Task { await control.setProxyEnabled(enabled) }
})
}
private func statusColor(_ summary: StatusMenuModel.Summary) -> Color {
switch control.snapshot.runtime.status {
case .running:
.green
case .starting:
.secondary
case .off:
.secondary
case .firewallBlocked, .requiresApproval:
.orange
case .launchFailed, .activationFailed, .serviceFailed, .systemProxyFailed:
.red
}
}
}