refactor(ui): 分区自挂页级动作与编辑/导出流程,壳收成薄转发

This commit is contained in:
Qiu Yuzhou
2026-10-03 23:03:30 +08:00
parent 9322184310
commit 56ad46bec0
4 changed files with 98 additions and 101 deletions
+43 -4
View File
@@ -2,15 +2,17 @@ import SwiftUI
/// 主窗口诊断区(spec #21 D11,issue #34/#43,地图 #52 票 #58):按原型重排
/// 为状态摘要卡(代理状态、活动目标)+ 日志卡(GUI 事件流实时呈现 + wrapper
/// 收敛日志尾部,来源切换 segmented、可复制)+ 底部脱敏说明行。导出诊断动作
/// 在窗口工具栏(由主窗口壳提供,保持 DiagnosticReportExportAction 既有
/// 入口)。事实采样、轮询代际与报告准备全部经 DiagnosticsWorkflow(issue #43
/// 的唯一 UI-facing seam);本视图只负责生命周期触发、呈现与复制动作。
/// 收敛日志尾部,来源切换 segmented、可复制)+ 底部脱敏说明行。导出诊断由
/// 本页自挂工具栏(呈现于窗口工具栏右端),沿用 DiagnosticReportExportAction
/// 既有入口,成功 alert 与错误弹窗均在本视图挂载。事实采样、轮询代际与报告
/// 准备全部经 DiagnosticsWorkflow(issue #43 的唯一 UI-facing seam);本视图
/// 只负责生命周期触发、呈现与复制动作。
struct DiagnosticsView: View {
@ObservedObject var diagnostics: DiagnosticsWorkflow
/// 共享错误弹窗呈现(UI 持有;typed error → 本地化文案的呈现边缘)。
@StateObject private var errors = ErrorAlertPresenter()
let clipboard: any TextClipboard
let exporter: any DiagnosticReportExporter
enum LogSource: String, CaseIterable, Identifiable {
case guiEvents
@@ -29,6 +31,7 @@ struct DiagnosticsView: View {
}
@State private var source: LogSource = .guiEvents
@State private var exportedDiagnosticsPath: String?
var body: some View {
ScrollView {
@@ -51,6 +54,42 @@ struct DiagnosticsView: View {
}
.task { await diagnostics.readWhileActive() }
.presentingErrors(errors)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("导出诊断…", systemImage: "square.and.arrow.up") {
exportDiagnostics()
}
}
}
.alert(
"诊断已导出",
isPresented: Binding(
get: { exportedDiagnosticsPath != nil },
set: { if !$0 { exportedDiagnosticsPath = nil } })
) {
Button("好", role: .cancel) {}
} message: {
Text(exportedDiagnosticsPath ?? "")
}
}
/// 诊断导出(票 #58):沿用 DiagnosticReportExportAction 既有入口;文件写
/// 入成功后才登记导出完成事件。
private func exportDiagnostics() {
switch DiagnosticReportExportAction(
diagnostics: diagnostics, exporter: exporter
).perform()
{
case .preparationFailed(let failure):
errors.present(failure)
case .cancelled:
break
case .saved(let url):
diagnostics.noteExportCompleted()
exportedDiagnosticsPath = url.path
case .exportFailed(let failure):
errors.present(failure)
}
}
// MARK: - 状态摘要
+17 -89
View File
@@ -21,24 +21,27 @@ private enum WorkspaceSheet: String, Identifiable {
/// 主窗口外壳(地图 #52,票 #53):NavigationSplitView 侧栏承载五项导航与
/// 底部常驻代理状态卡;详情区按 route destination 承载各分区视图。分区名即
/// 窗口标题(navigationTitle 绑定 route destination,ADR 0016 的 scene 版
/// 窗口原生呈现);订阅/诊断的页级动作经 .toolbar 桥接进窗口工具栏右端
/// (票 #56/#58 的槽位仅呈现位置变化),设置的表单级提交动作在其视图内容
/// 顶部(见 SettingsView)。路由状态仍由 WorkspaceRoute 持有;代理状态卡的
/// 窗口原生呈现);页级动作由各分区视图自挂 toolbar(呈现于窗口工具栏右
/// 端,与服务器分区同法,票 #56/#58 的槽位仅呈现位置),设置的表单级提交
/// 动作在其视图内容顶部(见 SettingsView)。路由状态仍由 WorkspaceRoute
/// 持有;代理状态卡的
/// 状态、摘要与配色政策全部经 StatusCardModel 从代理控制工作流的整体
/// snapshot 派生(issue #47,与状态菜单同口径),本壳只挂 StatusCardView;
/// 服务器分区的活动目标标记同源(snapshot.activeTarget)。
struct MainWindowView: View {
@ObservedObject var rulesWorkflow: RulesWorkflow
/// 纯转发的分区工作流与控制器:壳 body 不读它们(分区视图自行观察),
/// 用 let 转发,壳不重复订阅它们的发布。
let rulesWorkflow: RulesWorkflow
@ObservedObject var route: WorkspaceRoute
@ObservedObject var workflow: CatalogWorkflow
@ObservedObject var control: ProxyControlWorkflow
@ObservedObject var diagnostics: DiagnosticsWorkflow
@ObservedObject var settingsWorkflow: SettingsWorkflow
@ObservedObject var loginController: LaunchAtLoginController
@ObservedObject var silentLaunch: SilentLaunchController
let diagnostics: DiagnosticsWorkflow
let settingsWorkflow: SettingsWorkflow
let loginController: LaunchAtLoginController
let silentLaunch: SilentLaunchController
/// 目录树折叠状态:组合根持有的长寿命对象,跨 destination 切换存续;服务器
/// 侧栏使用(见 CatalogExpansionState);首页独立保存浏览状态。
@ObservedObject var expansion: CatalogExpansionState
let expansion: CatalogExpansionState
let clipboard: any TextClipboard
let diagnosticReportExporter: any DiagnosticReportExporter
let configurationGroupFileExporter: any ConfigurationGroupFileExporter
@@ -54,10 +57,8 @@ struct MainWindowView: View {
/// 初值须与 WorkspaceRoute 的初始 destination 对齐,否则首帧侧栏无高亮行。
@State private var sidebarSelection: WorkspaceDestination? = WorkspaceRoute.initialDestination
@State private var selection: NodeID?
@State private var ruleDraft: CustomRuleDraft?
/// 全局添加菜单打开的表单,以及诊断导出由窗口壳持有。
/// 全局添加菜单打开的表单由窗口壳持有。
@State private var presentedWorkspaceSheet: WorkspaceSheet?
@State private var exportedDiagnosticsPath: String?
@StateObject private var shellActionErrors = ErrorAlertPresenter()
var body: some View {
@@ -77,11 +78,6 @@ struct MainWindowView: View {
}
.navigationTitle(route.destination.label)
.frame(minWidth: 920, minHeight: 580)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
destinationActions
}
}
.sheet(item: $presentedWorkspaceSheet) { sheet in
switch sheet {
case .importURL:
@@ -96,26 +92,10 @@ struct MainWindowView: View {
AddSubscriptionSheet(workflow: workflow, errors: shellActionErrors)
}
}
.sheet(item: $ruleDraft) { draft in
CustomRuleEditorSheet(workflow: rulesWorkflow, draft: draft)
}
.onChange(of: route.destination) { _, destination in
if destination != .rules { ruleDraft = nil }
}
.presentingErrors(shellActionErrors)
.alert(
"诊断已导出",
isPresented: Binding(
get: { exportedDiagnosticsPath != nil },
set: { if !$0 { exportedDiagnosticsPath = nil } })
) {
Button("好", role: .cancel) {}
} message: {
Text(exportedDiagnosticsPath ?? "")
}
}
// MARK: - 分区动作槽位
// MARK: - 全局添加菜单
private var addMenu: some View {
Menu {
@@ -142,57 +122,6 @@ struct MainWindowView: View {
presentedWorkspaceSheet = sheet
}
/// 分区动作(票 #53/#56/#57):页级动作随 destination 切换,经工具栏桥接
/// 出现在窗口工具栏右端;首页/服务器无页级动作。例外:设置的保存/恢复
/// 默认是表单级提交动作,由 SettingsView 内容顶部行承载(动作与表单同置,
/// 工具栏呈现效果差)。sheet/alert 呈现状态仍由壳持有,本处只负责动作的
/// 呈现。
@ViewBuilder
private var destinationActions: some View {
switch route.destination {
case .subscriptions:
HStack(spacing: 8) {
if !workflow.refreshingSubscriptionIDs.isEmpty {
ProgressView()
.controlSize(.small)
}
Button("更新全部", systemImage: "arrow.triangle.2.circlepath") {
Task { await workflow.refreshAllSubscriptions() }
}
.disabled(workflow.subscriptions.isEmpty)
}
case .rules:
Button(RulesCopy.text("新增规则"), systemImage: "plus") {
ruleDraft = rulesWorkflow.makeCustomRuleDraft()
}.disabled(!rulesWorkflow.snapshot.isComplete)
case .diagnostics:
Button("导出诊断…", systemImage: "square.and.arrow.up") {
exportDiagnostics()
}
case .home, .servers, .settings:
EmptyView()
}
}
/// 诊断导出(票 #58):沿用 DiagnosticReportExportAction 既有入口;文件写
/// 入成功后才登记导出完成事件。
private func exportDiagnostics() {
switch DiagnosticReportExportAction(
diagnostics: diagnostics, exporter: diagnosticReportExporter
).perform()
{
case .preparationFailed(let failure):
shellActionErrors.present(failure)
case .cancelled:
break
case .saved(let url):
diagnostics.noteExportCompleted()
exportedDiagnosticsPath = url.path
case .exportFailed(let failure):
shellActionErrors.present(failure)
}
}
// MARK: - 分区承载
@ViewBuilder
@@ -220,9 +149,7 @@ struct MainWindowView: View {
workflow: workflow,
onNodesRemoved: clearSelectionIfInvalidated)
case .rules:
RulesView(
workflow: rulesWorkflow,
onEditRule: { ruleDraft = rulesWorkflow.makeCustomRuleDraft(editing: $0) })
RulesView(workflow: rulesWorkflow)
case .settings:
SettingsView(
workflow: settingsWorkflow, loginController: loginController,
@@ -232,7 +159,8 @@ struct MainWindowView: View {
case .diagnostics:
DiagnosticsView(
diagnostics: diagnostics,
clipboard: clipboard)
clipboard: clipboard,
exporter: diagnosticReportExporter)
}
}
+20 -5
View File
@@ -4,7 +4,9 @@ import SwiftUI
struct RulesView: View {
@ObservedObject var workflow: RulesWorkflow
@Environment(\.openWindow) private var openWindow
var onEditRule: (UUID) -> Void
/// 规则编辑草稿与编辑 sheet 由本页自持:@State 生命周期绑定在 rules
/// destination 分支上,离开分区即丢弃(与壳级清稿等价)。
@State private var ruleDraft: CustomRuleDraft?
var body: some View {
VStack(spacing: 0) {
@@ -45,6 +47,17 @@ struct RulesView: View {
.task {
if workflow.snapshot.version.isEmpty { await workflow.refresh() }
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(RulesCopy.text("新增规则"), systemImage: "plus") {
ruleDraft = workflow.makeCustomRuleDraft()
}
.disabled(!workflow.snapshot.isComplete)
}
}
.sheet(item: $ruleDraft) { draft in
CustomRuleEditorSheet(workflow: workflow, draft: draft)
}
}
@ViewBuilder
@@ -149,10 +162,12 @@ struct RulesView: View {
.width(min: 80, ideal: 110)
TableColumn("") { row in
if let id = row.customIDs.first {
Button(RulesCopy.text("编辑规则"), systemImage: "pencil") { onEditRule(id) }
.labelStyle(.iconOnly)
.help(RulesCopy.text("编辑规则"))
.disabled(!workflow.snapshot.isComplete)
Button(RulesCopy.text("编辑规则"), systemImage: "pencil") {
ruleDraft = workflow.makeCustomRuleDraft(editing: id)
}
.labelStyle(.iconOnly)
.help(RulesCopy.text("编辑规则"))
.disabled(!workflow.snapshot.isComplete)
}
}.width(30)
}.frame(minWidth: 0, maxWidth: .infinity)
+18 -3
View File
@@ -2,9 +2,10 @@ import SwiftUI
/// 订阅分区(spec #21 D11,issue #35/#41,地图 #52 票 #56):每订阅一张卡片
/// (图标 + 名称 + 「HTTPS 订阅地址已隐藏」+ 状态徽标 + 更新/重试 + 「…」菜单 +
/// 三列元数据 + 脱敏说明行),失败卡警告色描边。添加订阅通过窗口工具栏全局
/// 「添加」菜单进入,更新全部仍在本页工具栏。卡片数据来自订阅 projection
/// (非敏感);结构化刷新状态在呈现层本地化(story 42)。
/// 三列元数据 + 脱敏说明行),失败卡警告色描边。添加订阅通过窗口壳侧栏的
/// 「添加」菜单进入;「更新全部」由本页自挂工具栏(呈现于窗口工具栏右端,
/// 与服务器分区同法)。卡片数据来自订阅 projection(非敏感);结构化刷新
/// 状态在呈现层本地化(story 42)。
struct SubscriptionsView: View {
@ObservedObject var workflow: CatalogWorkflow
/// 共享错误弹窗呈现(UI 持有;typed error → 本地化文案的呈现边缘)。
@@ -62,6 +63,20 @@ struct SubscriptionsView: View {
)
}
.presentingErrors(errors)
.toolbar {
ToolbarItem(placement: .primaryAction) {
HStack(spacing: 8) {
if !workflow.refreshingSubscriptionIDs.isEmpty {
ProgressView()
.controlSize(.small)
}
Button("更新全部", systemImage: "arrow.triangle.2.circlepath") {
Task { await workflow.refreshAllSubscriptions() }
}
.disabled(workflow.subscriptions.isEmpty)
}
}
}
}
private func commitDelete() {