mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 05:33:45 +00:00
feat(ng2): restyle diagnostics and move export to header (#58)
摘要卡(代理状态/活动目标,来自 DiagnosticsWorkflow.summary 安全投影)+ 日志卡(来源 segmented + 复制 + 等宽日志区与空态)+ 脱敏说明行;状态摘要 侧栏删除;导出诊断迁分区头(DiagnosticReportExportAction 入口不变,「诊断 已导出」alert 移至壳层);分区包装视图归位 WorkspaceDestinations.swift。 地图 #52 · 票 #58;五分区 hermetic 冒烟验收通过,task build + test 全绿。
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 主窗口诊断区(spec #21 D11,issue #34/#43):日志查看器(GUI 事件流实时
|
||||
/// 呈现 + wrapper 收敛日志尾部,均可复制)与显式触发的脱敏诊断导出。事实
|
||||
/// 采样、轮询代际与报告准备全部经 DiagnosticsWorkflow(issue #43 的唯一
|
||||
/// UI-facing seam);本视图只负责生命周期触发、呈现与用户触发动作;剪贴板
|
||||
/// 和报告文件效果经显式 adapter 注入,文件实际写入成功后才登记导出完成事件。
|
||||
/// 主窗口诊断区(spec #21 D11,issue #34/#43,地图 #52 票 #58):按原型重排
|
||||
/// 为状态摘要卡(代理状态、活动目标)+ 日志卡(GUI 事件流实时呈现 + wrapper
|
||||
/// 收敛日志尾部,来源切换 segmented、可复制)+ 底部脱敏说明行。导出诊断动作
|
||||
/// 在分区头动作槽位(由主窗口壳提供,保持 DiagnosticReportExportAction 既有
|
||||
/// 入口)。事实采样、轮询代际与报告准备全部经 DiagnosticsWorkflow(issue #43
|
||||
/// 的唯一 UI-facing seam);本视图只负责生命周期触发、呈现与复制动作。
|
||||
struct DiagnosticsView: View {
|
||||
@ObservedObject var diagnostics: DiagnosticsWorkflow
|
||||
let errors: ErrorAlertPresenter
|
||||
let clipboard: any TextClipboard
|
||||
let reportExporter: any DiagnosticReportExporter
|
||||
|
||||
enum LogSource: String, CaseIterable, Identifiable {
|
||||
case guiEvents
|
||||
@@ -28,59 +28,96 @@ struct DiagnosticsView: View {
|
||||
}
|
||||
|
||||
@State private var source: LogSource = .guiEvents
|
||||
@State private var exportedPath: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
sourcePicker
|
||||
Divider()
|
||||
logPane
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
summaryRow
|
||||
logCard
|
||||
Text(
|
||||
"诊断导出只包含状态、存在性、权限和脱敏元数据,不包含密码、插件参数、"
|
||||
+ "订阅 URL 或运行时 JSON。"
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: 960, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 28)
|
||||
.padding(.trailing, 32)
|
||||
.padding(.top, 24)
|
||||
.padding(.bottom, 36)
|
||||
}
|
||||
.task { await diagnostics.readWhileActive() }
|
||||
.alert(
|
||||
"诊断已导出",
|
||||
isPresented: Binding(
|
||||
get: { exportedPath != nil },
|
||||
set: { if !$0 { exportedPath = nil } })
|
||||
) {
|
||||
Button("好", role: .cancel) {}
|
||||
} message: {
|
||||
Text(exportedPath ?? "")
|
||||
}
|
||||
|
||||
// MARK: - 状态摘要
|
||||
|
||||
private var summaryRow: some View {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
statCard("代理状态", value: diagnostics.summary.proxyState.label)
|
||||
statCard("活动目标", value: diagnostics.summary.hasActiveTarget ? "已设置" : "未设置")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 头部动作
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Text("诊断").font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
copyToClipboard(activeText)
|
||||
} label: {
|
||||
Label("复制", systemImage: "doc.on.doc")
|
||||
}
|
||||
.disabled(activeText.isEmpty)
|
||||
Button {
|
||||
exportReport()
|
||||
} label: {
|
||||
Label("导出诊断…", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
private func statCard(_ label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.callout.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
.help(value)
|
||||
}
|
||||
.padding(8)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
.background.secondary,
|
||||
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.quaternary)
|
||||
)
|
||||
}
|
||||
|
||||
private var sourcePicker: some View {
|
||||
Picker("日志来源", selection: $source) {
|
||||
ForEach(LogSource.allCases) { source in
|
||||
Text(source.label).tag(source)
|
||||
// MARK: - 日志卡
|
||||
|
||||
private var logCard: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
Picker("日志来源", selection: $source) {
|
||||
ForEach(LogSource.allCases) { source in
|
||||
Text(source.label).tag(source)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.frame(width: 320)
|
||||
Spacer(minLength: 12)
|
||||
Button {
|
||||
copyToClipboard(activeText)
|
||||
} label: {
|
||||
Label("复制", systemImage: "doc.on.doc")
|
||||
}
|
||||
.disabled(activeText.isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
Divider()
|
||||
logBox
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
.background.secondary,
|
||||
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.quaternary)
|
||||
)
|
||||
}
|
||||
|
||||
/// 当前日志来源的文本与空态提示(呈现与复制共用同一来源,story 21)。
|
||||
@@ -99,33 +136,32 @@ struct DiagnosticsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var logPane: some View {
|
||||
logText(activeLog.text, emptyMessage: activeLog.emptyMessage)
|
||||
}
|
||||
|
||||
private func logText(_ text: String, emptyMessage: String) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 2) {
|
||||
if text.isEmpty {
|
||||
Text(emptyMessage)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(12)
|
||||
} else {
|
||||
Text(text)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeText: String {
|
||||
activeLog.text
|
||||
}
|
||||
|
||||
private var logBox: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if activeText.isEmpty {
|
||||
Text(activeLog.emptyMessage)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(16)
|
||||
} else {
|
||||
Text(activeText)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.primary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(minHeight: 300, maxHeight: 390)
|
||||
}
|
||||
|
||||
private func copyToClipboard(_ text: String) {
|
||||
do {
|
||||
try clipboard.write(text)
|
||||
@@ -133,49 +169,4 @@ struct DiagnosticsView: View {
|
||||
errors.present(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 显式导出(唯一导出入口)
|
||||
|
||||
private func exportReport() {
|
||||
switch DiagnosticReportExportAction(
|
||||
diagnostics: diagnostics, exporter: reportExporter
|
||||
).perform()
|
||||
{
|
||||
case .preparationFailed(let failure):
|
||||
errors.present(failure)
|
||||
case .cancelled:
|
||||
break
|
||||
case .saved(let url):
|
||||
diagnostics.noteExportCompleted()
|
||||
exportedPath = url.path
|
||||
case .exportFailed(let failure):
|
||||
errors.present(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 诊断分区侧栏摘要(D11,story 25):与详情共用 DiagnosticsWorkflow 的同一
|
||||
/// 份安全摘要 projection。
|
||||
struct DiagnosticsSummarySidebar: View {
|
||||
@ObservedObject var workflow: DiagnosticsWorkflow
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("状态摘要") {
|
||||
LabeledContent("代理状态", value: workflow.summary.proxyState.label)
|
||||
LabeledContent(
|
||||
"活动目标", value: workflow.summary.hasActiveTarget ? "已设置" : "未设置")
|
||||
}
|
||||
Section {
|
||||
Text(
|
||||
"日志可在右侧查看与复制;「导出诊断」生成仅含脱敏元数据的报告"
|
||||
+ "(不含密码、插件参数、钥匙串值、订阅 URL、服务器地址与备注),"
|
||||
+ "供报障时提供。"
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 主窗口外壳(地图 #52,票 #53):NavigationSplitView 侧栏承载五项导航与
|
||||
/// 底部常驻代理状态卡;详情区按 route destination 承载既有五个分区视图,
|
||||
/// 顶部是系统设置风格的分区大标题(动作槽位留给各分区票填充)。路由状态
|
||||
/// 仍由 WorkspaceRoute 持有;代理状态卡的开关、模式与摘要只来自代理控制
|
||||
/// 工作流的整体 snapshot(issue #47),与状态菜单同一口径。
|
||||
/// 底部常驻代理状态卡;详情区按 route destination 承载各分区视图,顶部是
|
||||
/// 系统设置风格的分区大标题(动作槽位留给各分区票填充)。路由状态仍由
|
||||
/// WorkspaceRoute 持有;代理状态卡的开关、模式与摘要只来自代理控制工作流
|
||||
/// 的整体 snapshot(issue #47),与状态菜单同一口径。
|
||||
struct MainWindowView: View {
|
||||
@ObservedObject var route: WorkspaceRoute
|
||||
@ObservedObject var workflow: CatalogWorkflow
|
||||
@@ -17,8 +17,9 @@ struct MainWindowView: View {
|
||||
let diagnosticReportExporter: any DiagnosticReportExporter
|
||||
|
||||
@State private var selection: NodeID?
|
||||
/// 订阅分区头动作(票 #56):添加订阅 sheet 由壳持有。
|
||||
/// 分区头动作(票 #56/#58):添加订阅 sheet 与诊断导出的呈现状态由壳持有。
|
||||
@State private var showAddSubscription = false
|
||||
@State private var exportedDiagnosticsPath: String?
|
||||
@StateObject private var shellActionErrors = ErrorAlertPresenter()
|
||||
|
||||
var body: some View {
|
||||
@@ -47,6 +48,16 @@ struct MainWindowView: View {
|
||||
} message: {
|
||||
Text(shellActionErrors.message ?? "")
|
||||
}
|
||||
.alert(
|
||||
"诊断已导出",
|
||||
isPresented: Binding(
|
||||
get: { exportedDiagnosticsPath != nil },
|
||||
set: { if !$0 { exportedDiagnosticsPath = nil } })
|
||||
) {
|
||||
Button("好", role: .cancel) {}
|
||||
} message: {
|
||||
Text(exportedDiagnosticsPath ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 分区标题与动作槽位
|
||||
@@ -102,11 +113,34 @@ struct MainWindowView: View {
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!settingsWorkflow.canSave)
|
||||
}
|
||||
case .home, .servers, .diagnostics:
|
||||
case .diagnostics:
|
||||
Button("导出诊断…", systemImage: "square.and.arrow.up") {
|
||||
exportDiagnostics()
|
||||
}
|
||||
case .home, .servers:
|
||||
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
|
||||
@@ -134,8 +168,7 @@ struct MainWindowView: View {
|
||||
case .diagnostics:
|
||||
WorkspaceDiagnosticsView(
|
||||
diagnostics: diagnostics,
|
||||
clipboard: clipboard,
|
||||
reportExporter: diagnosticReportExporter)
|
||||
clipboard: clipboard)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,103 +179,6 @@ struct MainWindowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 主窗口各 destination 的包装视图(票 #53/#54):负责每个分区的共享
|
||||
/// alert/sheet 生命周期,行为委托给既有分区视图。
|
||||
|
||||
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 ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkspaceDiagnosticsView: View {
|
||||
@ObservedObject var diagnostics: DiagnosticsWorkflow
|
||||
let clipboard: any TextClipboard
|
||||
@StateObject private var errors = ErrorAlertPresenter()
|
||||
|
||||
var body: some View {
|
||||
// 导出诊断动作在分区头动作槽位(票 #58);本包装只承载复制错误呈现。
|
||||
DiagnosticsView(
|
||||
diagnostics: diagnostics,
|
||||
errors: errors,
|
||||
clipboard: clipboard
|
||||
)
|
||||
.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