import SwiftUI import UIKit // 引擎日志独立界面(#3):展示 EngineController.logs 环形缓冲里最近 N 条诊断(含 pion 内部 ICE/mDNS // 日志)。按传输会话(session id)聚合分组,每组可一键整体复制——便于真机调试时把某次传输的完整 // 日志一并贴出(无 Web Inspector)。另有「复制全部」「清空」。 struct LogView: View { @Environment(EngineController.self) private var engine private struct LogGroup: Identifiable { let id: String let session: String? let entries: [LogEntry] } // 按 sessionId 聚合:保持组内最新在前;组按首次出现(即最新条目)顺序。非会话日志归入「其他」组。 private var groups: [LogGroup] { var order: [String] = [] var map: [String: [LogEntry]] = [:] for e in engine.logs { let key = e.sessionId ?? "" if map[key] == nil { order.append(key) } map[key, default: []].append(e) } return order.map { LogGroup(id: $0.isEmpty ? "—" : $0, session: $0.isEmpty ? nil : $0, entries: map[$0] ?? []) } } var body: some View { Group { if engine.logs.isEmpty { ContentUnavailableView(t("ios.logs.empty"), systemImage: "doc.text") } else { List { ForEach(groups) { group in Section { ForEach(group.entries) { entry in logRow(entry) } } header: { HStack { Text(sessionLabel(group)) Spacer() Button { copy(group.entries) } label: { Image(systemName: "doc.on.doc") } .buttonStyle(.borderless) } } } } .listStyle(.plain) } } .navigationTitle(t("ios.logs.title")) .navigationBarTitleDisplayMode(.inline) .toolbar { if !engine.logs.isEmpty { ToolbarItem(placement: .topBarTrailing) { Button { copy(engine.logs) } label: { Label(t("ios.logs.copy"), systemImage: "doc.on.doc") } } ToolbarItem(placement: .topBarTrailing) { Button(role: .destructive) { engine.clearLogs() } label: { Label(t("ios.logs.clear"), systemImage: "trash") } } } } } // 复制为纯文本进剪贴板:存储为最新在前,复制时反转成时间正序便于阅读。 private func copy(_ entries: [LogEntry]) { let text = entries.reversed() .map { "\(timeString($0.at)) \($0.message)" } .joined(separator: "\n") UIPasteboard.general.string = text } private func sessionLabel(_ group: LogGroup) -> String { guard let s = group.session else { return "\(t("ios.logs.ungrouped")) · \(group.entries.count)" } return "\(t("ios.logs.session")) \(String(s.prefix(8))) · \(group.entries.count)" } private func logRow(_ entry: LogEntry) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Image(systemName: levelSymbol(entry.level)) .font(.caption) .foregroundStyle(levelTint(entry.level)) Text(timeString(entry.at)) .font(.caption2) .foregroundStyle(.secondary) .monospacedDigit() } Text(entry.message) .font(.caption.monospaced()) .foregroundStyle(.primary) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } .padding(.vertical, 2) } private func levelSymbol(_ level: String) -> String { switch level { case "error": return "xmark.octagon.fill" case "warn": return "exclamationmark.triangle.fill" default: return "info.circle" } } private func levelTint(_ level: String) -> Color { switch level { case "error": return .red case "warn": return .orange default: return .secondary } } private func timeString(_ date: Date) -> String { let f = DateFormatter() f.dateFormat = "HH:mm:ss" return f.string(from: date) } }