import SwiftUI struct LogsView: View { private enum Tab: Hashable { case runtime case connections case diagnostics } @StateObject private var model: LogsViewModel @State private var tab: Tab = .runtime @State private var showsPruneDialog = false init(model: LogsViewModel? = nil) { _model = StateObject(wrappedValue: model ?? DefaultContainer.makeLogsModel()) } var body: some View { ZStack { AITheme.canvas.ignoresSafeArea() ScrollView { LazyVStack(spacing: 12) { if let errorMessage = model.errorMessage { AICard { Text(errorMessage) .font(.subheadline) .foregroundStyle(AITheme.warning) } } stats Picker("视图", selection: $tab) { Text("运行日志").tag(Tab.runtime) Text("连接日志").tag(Tab.connections) Text("设备诊断").tag(Tab.diagnostics) } .pickerStyle(.segmented) switch tab { case .runtime: runtimeSection case .connections: connectionSection case .diagnostics: diagnosticSection } } .padding(16) } } .navigationTitle("日志中心") .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button { Task { await model.refresh() } } label: { Label("刷新", systemImage: "arrow.clockwise") } .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular)) .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 { await model.refresh() } } private var stats: some View { AICard { VStack(spacing: 12) { filterBar HStack(spacing: 8) { metric("日志", "\(model.snapshot?.runtime.count ?? 0)", AITheme.primary) metric("在线", "\(onlineCount)", AITheme.success) metric("离线", "\(model.offlineCount)", AITheme.danger) } } } } private var filterBar: some View { VStack(spacing: 10) { AISearchField("搜索日志、错误码或实例", text: $model.searchText) Picker("实例", selection: $model.selectedInstanceID) { Text("全部实例").tag(Optional.none) ForEach(model.instances) { instance in Text(instance.name).tag(Optional(instance.id)) } } .pickerStyle(.menu) if tab == .runtime { Picker("来源", selection: $model.selectedSource) { Text("全部来源").tag(Optional.none) ForEach(RuntimeLogSource.allCases, id: \.self) { source in Text(source.label).tag(Optional(source)) } } .pickerStyle(.menu) Picker("级别", selection: $model.selectedLevel) { Text("全部级别").tag(Optional.none) ForEach(RuntimeLogLevel.allCases, id: \.self) { level in Text(level.label).tag(Optional(level)) } } .pickerStyle(.menu) } 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) } } } private var runtimeSection: some View { AICard { VStack(alignment: .leading, spacing: 10) { sectionTitle("统一时间线", detail: "\(model.filteredRuntime.count) 条") if model.filteredRuntime.isEmpty { empty("暂无运行日志。") } else { ForEach(model.filteredRuntime) { entry in VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { Text(entry.message) .font(.subheadline.weight(.semibold)) Spacer() AITag(entry.level.label, tint: levelTint(entry.level)) } Text("\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())") .font(.caption) .foregroundStyle(AITheme.muted) HStack(spacing: 6) { AITag(entry.source.label, tint: AITheme.primary) if let duration = entry.durationMs { AITag("\(duration) ms", tint: AITheme.muted) } } } .padding(.bottom, 6) } } } } } private var connectionSection: some View { AICard { VStack(alignment: .leading, spacing: 10) { HStack { sectionTitle("连接探测记录", detail: "\(model.filteredConnections.count) 条") Button { showsPruneDialog = true } label: { Label("清理", systemImage: "trash") } .buttonStyle(AIButtonStyle(kind: .secondary, size: .compact)) } if model.filteredConnections.isEmpty { empty("暂无连接日志。") } else { ForEach(model.filteredConnections) { entry in VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { Text(entry.instanceName) .font(.subheadline.weight(.semibold)) Spacer() AITag(entry.outcome.label, tint: outcomeTint(entry.outcome)) } Text("\(entry.observedAt.formatted()) · \(entry.state)") .font(.caption) .foregroundStyle(AITheme.muted) Text("HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms · \(entry.errorCode ?? "--")") .font(.caption) .foregroundStyle(AITheme.muted) } .padding(.bottom, 6) } } } } } private var diagnosticSection: some View { AICard { VStack(alignment: .leading, spacing: 10) { sectionTitle("设备诊断", detail: "\(model.filteredDiagnostics.count) 台") if model.filteredDiagnostics.isEmpty { empty("暂无可诊断实例。") } else { ForEach(model.filteredDiagnostics) { diagnostic in VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Text(diagnostic.name) .font(.subheadline.weight(.semibold)) Spacer() AITag( diagnostic.healthStatus ?? "未知", tint: diagnostic.state == .online ? AITheme.success : AITheme.warning ) } Text(diagnostic.origin) .font(.caption) .foregroundStyle(AITheme.muted) if let version = diagnostic.version { Text("版本 \(version) · \(diagnostic.platform ?? "未知平台")") .font(.caption) .foregroundStyle(AITheme.muted) } HStack(spacing: 6) { AITag( "能力 \(diagnostic.capabilities.supported)/\(diagnostic.capabilities.total)", tint: AITheme.primary ) if let connection = diagnostic.connection { AITag( "可用 \(connection.availabilityPercent)%", tint: connection.failed == 0 ? AITheme.success : AITheme.warning ) } } } .padding(.bottom, 8) } } } } } private var onlineCount: Int { (model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0 } private func metric(_ title: String, _ value: String, _ tint: Color) -> some View { VStack(spacing: 4) { Text(title) .font(.caption) .foregroundStyle(AITheme.muted) Text(value) .font(.headline.weight(.bold)) .foregroundStyle(tint) } .frame(maxWidth: .infinity) } private func sectionTitle(_ title: String, detail: String) -> some View { HStack { Text(title) .font(.headline) Spacer() Text(detail) .font(.caption) .foregroundStyle(AITheme.muted) } } private func empty(_ text: String) -> some View { Text(text) .font(.subheadline) .foregroundStyle(AITheme.muted) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 18) } 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 } } }