mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-01 15:43:45 +00:00
feat(ng2): add home view with mode, target tree and quick actions (#54)
代理模式卡(PAC/全局 segmented + 说明 + 当前徽标,走 snapshot 可用模式)、 当前服务器卡(只含有服务器的分组,双击激活,活动目标勾选与无效警示)、 快速操作卡(更新全部订阅、复制终端 HTTP 导出行);Legacy 首启询问保留。 地图 #52 · 票 #54;task build + test 全绿,hermetic 冒烟通过。
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 首页卡片容器(地图 #52,票 #54):标题 + 副题 + 头部尾随内容 + 正文;
|
||||
/// 圆角描边卡片底,各分区票的分区卡片可复用。
|
||||
struct HomeCard<Trailing: View, Content: View>: View {
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
@ViewBuilder var trailing: () -> Trailing
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.title3.weight(.semibold))
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 16)
|
||||
trailing()
|
||||
}
|
||||
content()
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
.background.secondary,
|
||||
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.quaternary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前模式徽标(票 #54)。
|
||||
struct ModeBadge: View {
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
Text(label)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.accentColor.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
/// 快速操作行按钮(票 #54)。
|
||||
struct QuickActionButton: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
var help: String?
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(.tint)
|
||||
Text(title)
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(
|
||||
.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
)
|
||||
.help(help ?? title)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 首页分区(地图 #52,票 #54):代理模式切换、当前服务器目标树与快速操作。
|
||||
/// 模式与开关走代理控制工作流的整体 snapshot(issue #47);目标树与激活走
|
||||
/// 目录工作流 projection 与 `activate`;复制 HTTP 导出是 UI 副作用。原型中
|
||||
/// 的「手动/外部 PAC」模式不实现:Domain 只有 PAC/全局两种模式。
|
||||
struct HomeView: View {
|
||||
@ObservedObject var workflow: CatalogWorkflow
|
||||
@ObservedObject var control: ProxyControlWorkflow
|
||||
let clipboard: any TextClipboard
|
||||
let onManageServers: () -> Void
|
||||
let errors: ErrorAlertPresenter
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
VStack(spacing: 20) {
|
||||
ModeCard(control: control)
|
||||
TargetTreeCard(
|
||||
workflow: workflow,
|
||||
control: control,
|
||||
onManageServers: onManageServers,
|
||||
errors: errors)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
QuickActionCard(workflow: workflow, control: control, clipboard: clipboard, errors: errors)
|
||||
.frame(width: 300)
|
||||
}
|
||||
.padding(.leading, 28)
|
||||
.padding(.trailing, 32)
|
||||
.padding(.top, 24)
|
||||
.padding(.bottom, 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「代理模式」卡:可用模式切换(Domain 单点策略投影)+ 模式说明 + 当前徽标。
|
||||
private struct ModeCard: View {
|
||||
@ObservedObject var control: ProxyControlWorkflow
|
||||
|
||||
var body: some View {
|
||||
HomeCard(
|
||||
title: "代理模式",
|
||||
subtitle: "选择系统代理接管方式",
|
||||
trailing: { ModeBadge(label: control.snapshot.proxyMode.label) },
|
||||
content: {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Picker("代理模式", selection: modeBinding) {
|
||||
ForEach(control.snapshot.availableModes, id: \.self) { mode in
|
||||
Text(mode.label).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 480, alignment: .leading)
|
||||
Text(modeDescription(control.snapshot.proxyMode))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
})
|
||||
}
|
||||
|
||||
private var modeBinding: Binding<ProxyMode> {
|
||||
Binding(
|
||||
get: { control.snapshot.proxyMode },
|
||||
set: { mode in
|
||||
Task { await control.setProxyMode(mode) }
|
||||
})
|
||||
}
|
||||
|
||||
private func modeDescription(_ mode: ProxyMode) -> String {
|
||||
switch mode {
|
||||
case .pac:
|
||||
"根据规则自动决定直连或通过代理,适合日常使用。"
|
||||
case .global:
|
||||
"所有系统代理流量通过本地 SOCKS5 端点转发,不使用规则分流。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「当前服务器」卡:只显示有服务器的分组;双击节点激活为当前服务器。
|
||||
private struct TargetTreeCard: View {
|
||||
@ObservedObject var workflow: CatalogWorkflow
|
||||
@ObservedObject var control: ProxyControlWorkflow
|
||||
let onManageServers: () -> Void
|
||||
let errors: ErrorAlertPresenter
|
||||
|
||||
/// 收起状态的目标分组(默认全部展开)。
|
||||
@State private var collapsedGroups: Set<NodeID> = []
|
||||
|
||||
var body: some View {
|
||||
HomeCard(
|
||||
title: "当前服务器",
|
||||
subtitle: "只显示有服务器的分组,双击节点以设为当前服务器",
|
||||
trailing: {
|
||||
Button(action: onManageServers) {
|
||||
HStack(spacing: 4) {
|
||||
Text("管理服务器")
|
||||
Image(systemName: "arrow.right")
|
||||
}
|
||||
.font(.callout.weight(.medium))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.tint)
|
||||
},
|
||||
content: {
|
||||
tree.padding(.top, 6)
|
||||
})
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var tree: some View {
|
||||
let groups = targetGroups
|
||||
if groups.isEmpty {
|
||||
Text("暂无服务器;在「服务器」分区导入 ss:// 链接或新建分组。")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(groups) { group in
|
||||
TargetGroupSection(
|
||||
group: group,
|
||||
activeTargetID: control.snapshot.activeTarget?.id,
|
||||
isCollapsed: isCollapsed(group.id),
|
||||
onToggle: { toggleCollapsed(group.id) },
|
||||
onActivate: activate)
|
||||
}
|
||||
ForEach(rootServers) { server in
|
||||
TargetServerRow(
|
||||
server: server,
|
||||
isActive: control.snapshot.activeTarget?.id == server.id,
|
||||
onActivate: activate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func activate(_ id: NodeID) {
|
||||
Task {
|
||||
do {
|
||||
_ = try await workflow.activate(id)
|
||||
} catch {
|
||||
errors.present(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleCollapsed(_ id: NodeID) {
|
||||
if isCollapsed(id) {
|
||||
collapsedGroups.remove(id)
|
||||
} else {
|
||||
collapsedGroups.insert(id)
|
||||
}
|
||||
}
|
||||
|
||||
private func isCollapsed(_ id: NodeID) -> Bool {
|
||||
collapsedGroups.contains(id)
|
||||
}
|
||||
|
||||
/// 有服务器叶子的分组(递归收集;每组只呈现直属服务器叶子)。
|
||||
private var targetGroups: [TargetGroupInfo] {
|
||||
var groups: [TargetGroupInfo] = []
|
||||
func walk(_ nodes: [CatalogTreeNode]) {
|
||||
for node in nodes where node.isGroup {
|
||||
let servers = node.childNodes.filter { !$0.isGroup }
|
||||
if !servers.isEmpty {
|
||||
groups.append(
|
||||
TargetGroupInfo(
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
sourceLabel: node.source == .subscription ? "订阅分组" : "手动分组",
|
||||
servers: servers))
|
||||
}
|
||||
walk(node.childNodes.filter { $0.isGroup })
|
||||
}
|
||||
}
|
||||
walk(workflow.tree.roots)
|
||||
return groups
|
||||
}
|
||||
|
||||
/// 根层直接挂的服务器叶子(目录根层允许放服务器时兜底呈现)。
|
||||
private var rootServers: [CatalogTreeNode] {
|
||||
workflow.tree.roots.filter { !$0.isGroup }
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个目标分组:展开/收起头 + 直属服务器行。
|
||||
private struct TargetGroupSection: View {
|
||||
let group: TargetGroupInfo
|
||||
let activeTargetID: NodeID?
|
||||
let isCollapsed: Bool
|
||||
let onToggle: () -> Void
|
||||
let onActivate: (NodeID) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Button(action: onToggle) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
.rotationEffect(.degrees(isCollapsed ? 0 : 90))
|
||||
Image(systemName: "folder")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(group.name)
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
Text("\(group.sourceLabel) · \(group.servers.count) 台")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if !isCollapsed {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(group.servers) { server in
|
||||
TargetServerRow(
|
||||
server: server,
|
||||
isActive: activeTargetID == server.id,
|
||||
onActivate: onActivate)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// 目标树里的服务器行:双击激活,活动目标勾选标记。
|
||||
private struct TargetServerRow: View {
|
||||
let server: CatalogTreeNode
|
||||
let isActive: Bool
|
||||
let onActivate: (NodeID) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(.quaternary)
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay {
|
||||
Image(systemName: "server.rack")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(server.name)
|
||||
.font(.callout.weight(isActive ? .semibold : .regular))
|
||||
.foregroundStyle(server.isInvalid ? .secondary : .primary)
|
||||
Text(server.source == .subscription ? "订阅节点" : "手动服务器")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if server.isInvalid {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.help("该服务器存在已知阻塞问题,激活会被点名拒绝")
|
||||
}
|
||||
Image(systemName: "checkmark")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.tint)
|
||||
.opacity(isActive ? 1 : 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
isActive ? Color.accentColor.opacity(0.10) : Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) {
|
||||
onActivate(server.id)
|
||||
}
|
||||
.help("双击设为当前服务器")
|
||||
}
|
||||
}
|
||||
|
||||
/// 「快速操作」卡:更新全部订阅、复制终端 HTTP 代理导出行。
|
||||
private struct QuickActionCard: View {
|
||||
@ObservedObject var workflow: CatalogWorkflow
|
||||
@ObservedObject var control: ProxyControlWorkflow
|
||||
let clipboard: any TextClipboard
|
||||
let errors: ErrorAlertPresenter
|
||||
|
||||
@State private var isRefreshingAll = false
|
||||
|
||||
var body: some View {
|
||||
HomeCard(
|
||||
title: "快速操作",
|
||||
subtitle: nil,
|
||||
trailing: { EmptyView() },
|
||||
content: {
|
||||
VStack(spacing: 10) {
|
||||
QuickActionButton(
|
||||
icon: "arrow.triangle.2.circlepath",
|
||||
title: isRefreshingAll ? "正在更新…" : "更新全部订阅",
|
||||
help: nil,
|
||||
action: refreshAll
|
||||
)
|
||||
.disabled(workflow.subscriptions.isEmpty || isRefreshingAll)
|
||||
QuickActionButton(
|
||||
icon: "doc.on.doc",
|
||||
title: "复制终端 HTTP 代理指令",
|
||||
help: copyHelp,
|
||||
action: copyHTTPExport
|
||||
)
|
||||
.disabled(control.snapshot.httpExport == nil)
|
||||
if isRefreshingAll {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
})
|
||||
}
|
||||
|
||||
private var copyHelp: String? {
|
||||
control.snapshot.httpExport.map { _ in "复制可在 shell 中 source 的 http/https 代理导出行" }
|
||||
?? "HTTP 代理未启用,无法导出"
|
||||
}
|
||||
|
||||
private func refreshAll() {
|
||||
isRefreshingAll = true
|
||||
Task {
|
||||
await workflow.refreshAllSubscriptions()
|
||||
isRefreshingAll = false
|
||||
}
|
||||
}
|
||||
|
||||
private func copyHTTPExport() {
|
||||
guard let line = control.snapshot.httpExport?.copyableLine else { return }
|
||||
do {
|
||||
try clipboard.write(line)
|
||||
} catch {
|
||||
errors.present(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 目标分组的呈现模型(票 #54):名称、来源说明与直属服务器叶子。
|
||||
private struct TargetGroupInfo: Identifiable {
|
||||
let id: NodeID
|
||||
let name: String
|
||||
let sourceLabel: String
|
||||
let servers: [CatalogTreeNode]
|
||||
}
|
||||
@@ -71,7 +71,11 @@ struct MainWindowView: View {
|
||||
private var destinationView: some View {
|
||||
switch route.destination {
|
||||
case .home:
|
||||
WorkspaceHomeView(workflow: workflow)
|
||||
WorkspaceHomeView(
|
||||
workflow: workflow,
|
||||
control: control,
|
||||
clipboard: clipboard,
|
||||
onManageServers: { route.navigate(to: .servers) })
|
||||
case .servers:
|
||||
ServersView(
|
||||
workflow: workflow,
|
||||
@@ -104,14 +108,21 @@ struct MainWindowView: View {
|
||||
|
||||
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 {
|
||||
ContentUnavailableView(
|
||||
"首页", systemImage: "house",
|
||||
description: Text("从侧栏选择服务器、订阅、设置或诊断。")
|
||||
HomeView(
|
||||
workflow: workflow,
|
||||
control: control,
|
||||
clipboard: clipboard,
|
||||
onManageServers: onManageServers,
|
||||
errors: errors
|
||||
)
|
||||
.sheet(isPresented: $showLegacyImportSheet) {
|
||||
LegacyImportSheet(workflow: workflow)
|
||||
@@ -121,6 +132,16 @@ private struct WorkspaceHomeView: View {
|
||||
didOfferLegacyImport = true
|
||||
showLegacyImportSheet = true
|
||||
}
|
||||
.alert(
|
||||
"操作失败",
|
||||
isPresented: Binding(
|
||||
get: { errors.isPresented },
|
||||
set: { if !$0 { errors.dismiss() } })
|
||||
) {
|
||||
Button("好", role: .cancel) {}
|
||||
} message: {
|
||||
Text(errors.message ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user