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:
Qiu Yuzhou
2026-09-24 19:30:49 +08:00
parent 678bea6d3b
commit 75de3fbac9
3 changed files with 468 additions and 4 deletions
+86
View File
@@ -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)
}
}
+357
View File
@@ -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]
}
+25 -4
View File
@@ -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 ?? "")
}
}
}