import SwiftUI struct LogsView: View { private enum Tab: Hashable { case runtime case connections case diagnostics } @StateObject private var model: LogsViewModel private let autoRefreshOnAppear: Bool @State private var tab: Tab = .runtime @State private var showsPruneDialog = false init(model: LogsViewModel? = nil) { let resolvedModel = model ?? DefaultContainer.makeLogsModel(autoRefreshOnAppear: false) _model = StateObject(wrappedValue: resolvedModel) self.autoRefreshOnAppear = resolvedModel.autoRefreshOnAppear } private var isUnavailable: Bool { model.snapshot == nil && model.errorMessage != nil && !model.isLoading } /// 空列表配一排筛选器只是噪音,有内容才给筛选入口。 private var hasRowsForTab: Bool { switch tab { case .runtime: return model.filteredRuntime.isEmpty == false case .connections: return model.filteredConnections.isEmpty == false case .diagnostics: return model.filteredDiagnostics.isEmpty == false } } var body: some View { ScrollView { LazyVStack(spacing: 12) { if isUnavailable { AICard { AIEmptyState( systemImage: "arrow.triangle.2.circlepath", title: "未能读取日志", message: model.errorMessage ?? "所有节点都没有返回日志数据。", actionTitle: "重试" ) { Task { await model.refresh() } } } } else if model.snapshot == nil && model.isLoading { AILoadingCard("正在同步日志数据") } else { stats AISegmentedControl( "日志视图", selection: $tab, options: [ (value: Tab.runtime, title: "运行日志"), (value: Tab.connections, title: "连接日志"), (value: Tab.diagnostics, title: "设备诊断"), ] ) if hasRowsForTab { filterBar } if let errorMessage = model.errorMessage { AIStatusBanner( errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle") } switch tab { case .runtime: runtimeSection case .connections: connectionSection case .diagnostics: diagnosticSection } } } .padding(16) .padding(.bottom, 8) } .background(AITheme.canvas.ignoresSafeArea()) .navigationTitle("日志中心") .toolbar { ToolbarItemGroup(placement: .primaryAction) { AIIconButton("刷新", systemImage: "arrow.clockwise") { Task { await model.refresh() } } .disabled(model.isLoading) } } .confirmationDialog( "清理 7 天前的连接日志?操作不可恢复。", isPresented: $showsPruneDialog, titleVisibility: .visible ) { Button("清理连接日志", role: .destructive) { Task { _ = await model.pruneConnections(olderThanDays: 7) } } Button("取消", role: .cancel) {} } .refreshable { await model.refresh() } .task { if model.snapshot == nil && autoRefreshOnAppear { await model.refresh() } } } /// 三块全 0 的读数和下方的空态说的是同一件事,没有记录时整段不出现。 private var stats: some View { let runtime = model.snapshot?.runtime.count ?? 0 let connections = model.snapshot?.connections.count ?? 0 let diagnostics = model.snapshot?.diagnostics.count ?? 0 return Group { if runtime + connections + diagnostics > 0 { LazyVGrid( columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12 ) { AIStatCard( title: "运行日志", value: "\(runtime)", systemImage: "list.bullet", tint: AITheme.primary) AIStatCard( title: "连接探测", value: "\(connections)", systemImage: "dot.radiowaves.left.and.right", tint: model.offlineCount == 0 ? AITheme.success : AITheme.warning) AIStatCard( title: "设备诊断", value: "\(diagnostics)", systemImage: "iphone.gen3", tint: AITheme.ink) } } } } private var filterBar: some View { VStack(spacing: 8) { AISearchField("搜索日志、错误码或实例", text: $model.searchText, minHeight: AITheme.compactControlHeight) Picker("实例", selection: $model.selectedInstanceID) { Text("全部实例").tag(Optional.none) ForEach(model.instances) { instance in Text(instance.name).tag(Optional(instance.id)) } } .pickerStyle(.menu) .aiSelectSurface(compact: true) if tab == .runtime { HStack(spacing: 8) { Picker("来源", selection: $model.selectedSource) { Text("全部来源").tag(Optional.none) ForEach(RuntimeLogSource.allCases, id: \.self) { source in Text(source.label).tag(Optional(source)) } } .pickerStyle(.menu) .aiSelectSurface(compact: true) Picker("级别", selection: $model.selectedLevel) { Text("全部级别").tag(Optional.none) ForEach(RuntimeLogLevel.allCases, id: \.self) { level in Text(level.label).tag(Optional(level)) } } .pickerStyle(.menu) .aiSelectSurface(compact: true) } } else if tab == .connections { Picker("结果", selection: $model.selectedOutcome) { Text("全部结果").tag(Optional.none) ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in Text(outcome.label).tag(Optional(outcome)) } } .pickerStyle(.menu) .aiSelectSurface(compact: true) } } .animation(.easeInOut(duration: 0.18), value: tab) } private var runtimeSection: some View { VStack(alignment: .leading, spacing: 10) { AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条") if model.filteredRuntime.isEmpty { AICard { AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "所有节点都没有返回运行记录。") } } else { ForEach(model.filteredRuntime) { entry in AIListCard( title: entry.message, subtitle: "\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())", status: entry.level.label, statusTint: levelTint(entry.level), details: { var values = [entry.source.label] if let duration = entry.durationMs { values.append("耗时 \(duration) ms") } return values }() ) } } } } private var connectionSection: some View { VStack(alignment: .leading, spacing: 10) { AISectionTitle( "连接探测记录", systemImage: "dot.radiowaves.left.and.right", detail: "\(model.filteredConnections.count) 条", actionTitle: "清理", actionSystemImage: "trash", actionKind: .danger ) { showsPruneDialog = true } if model.filteredConnections.isEmpty { AICard { AIEmptyState( systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。" ) } } else { ForEach(model.filteredConnections) { entry in AIListCard( title: entry.instanceName, subtitle: "\(entry.observedAt.formatted()) · \(entry.state)", status: entry.outcome.label, statusTint: outcomeTint(entry.outcome), details: [ "HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms", entry.errorCode ?? "无错误码", ] ) } } } } private var diagnosticSection: some View { VStack(alignment: .leading, spacing: 10) { AISectionTitle( "设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台") if model.filteredDiagnostics.isEmpty { AICard { AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。") } } else { ForEach(model.filteredDiagnostics) { diagnostic in AIListCard( title: diagnostic.name, status: diagnostic.healthStatus ?? "未知", statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning, details: [ diagnostic.origin, "版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")", ] ) { LazyVGrid( columns: [GridItem(.adaptive(minimum: 82), spacing: 6)], alignment: .leading, spacing: 6 ) { ForEach(diagnostic.featureStatus) { feature in AITag(feature.name, tint: featureTint(feature.state)) } if let connection = diagnostic.connection, connection.total > 1 { AITag( "延迟 \(connection.averageDurationMs) ms", tint: connection.averageDurationMs <= 1500 ? AITheme.success : AITheme.warning ) } } } } } } } private func levelTint(_ level: RuntimeLogLevel) -> Color { switch level { case .info: return AITheme.success case .warning: return AITheme.warning case .error: return AITheme.danger } } private func outcomeTint(_ outcome: ConnectionOutcome) -> Color { switch outcome { case .success: return AITheme.success case .stale: return AITheme.warning case .failed: return AITheme.danger case .unsupported: return AITheme.muted } } private func featureTint(_ state: FeatureStatus.State) -> Color { switch state { case .ready: return AITheme.success case .requiresLogin: return AITheme.warning case .unavailable: return AITheme.muted } } }