import SwiftUI import UIKit struct DeveloperLogEntry: Identifiable, Equatable { enum Level: String { case info = "信息" case success = "成功" case warning = "警告" case error = "错误" case transmit = "发送" case receive = "接收" } let id = UUID() let timestamp: Date let category: String let level: Level let message: String } final class DeveloperLogStore: ObservableObject, @unchecked Sendable { static let shared = DeveloperLogStore() static let enabledKey = "com.celestia.trace.developer_mode" @Published private(set) var entries: [DeveloperLogEntry] = [] private init() {} static func log( _ category: String, _ message: String, level: DeveloperLogEntry.Level = .info ) { guard UserDefaults.standard.bool(forKey: enabledKey) else { return } let entry = DeveloperLogEntry( timestamp: Date(), category: category, level: level, message: message ) DispatchQueue.main.async { shared.entries.append(entry) if shared.entries.count > 500 { shared.entries.removeFirst(shared.entries.count - 500) } } } func clear() { entries.removeAll() } var copyableText: String { entries.map { entry in "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.category)] [\(entry.level.rawValue)] \(entry.message)" } .joined(separator: "\n") } private static let timestampFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "HH:mm:ss.SSS" return formatter }() } struct DeveloperLogOverlay: View { @ObservedObject private var logStore = DeveloperLogStore.shared @State private var isCollapsed = false @State private var dragOffset: CGSize = .zero @State private var dragStartOffset: CGSize = .zero var body: some View { VStack(spacing: 0) { header if !isCollapsed { Divider() .overlay(Color.white.opacity(0.12)) ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 7) { if logStore.entries.isEmpty { Text("等待蓝牙、同步及 App 运行日志…") .foregroundStyle(.white.opacity(0.55)) } else { ForEach(logStore.entries) { entry in logRow(entry) .id(entry.id) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(10) } .frame(height: 210) .onChange(of: logStore.entries.last?.id) { _, id in guard let id else { return } withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo(id, anchor: .bottom) } } } } } .containerRelativeFrame(.horizontal) { availableWidth, _ in min(max(availableWidth - 24, 240), 340) } .background(.black.opacity(0.88), in: RoundedRectangle(cornerRadius: 12)) .overlay { RoundedRectangle(cornerRadius: 12) .stroke(Color.celestiaCyan.opacity(0.65), lineWidth: 1) } .shadow(color: .black.opacity(0.35), radius: 12, y: 5) .offset(dragOffset) .padding(.horizontal, 12) .padding(.bottom, 62) .accessibilityElement(children: .contain) .accessibilityLabel("开发者日志悬浮框") } private var header: some View { HStack(spacing: 10) { Circle() .fill(Color.green) .frame(width: 7, height: 7) Text("开发者日志") .font(.system(size: 13, weight: .semibold, design: .monospaced)) .foregroundStyle(.white) Spacer() Text("\(logStore.entries.count)") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.white.opacity(0.55)) Button { UIPasteboard.general.string = logStore.copyableText } label: { Image(systemName: "doc.on.doc") } .disabled(logStore.entries.isEmpty) Button { logStore.clear() } label: { Image(systemName: "trash") } .disabled(logStore.entries.isEmpty) Button { withAnimation(.easeInOut(duration: 0.18)) { isCollapsed.toggle() } } label: { Image(systemName: isCollapsed ? "chevron.up" : "chevron.down") } } .font(.system(size: 12, weight: .medium)) .foregroundStyle(Color.celestiaCyan) .padding(.horizontal, 11) .frame(height: 40) .contentShape(Rectangle()) .gesture( DragGesture() .onChanged { value in dragOffset = CGSize( width: dragStartOffset.width + value.translation.width, height: dragStartOffset.height + value.translation.height ) } .onEnded { _ in dragStartOffset = dragOffset } ) } private func logRow(_ entry: DeveloperLogEntry) -> some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 5) { Text(Self.timeFormatter.string(from: entry.timestamp)) .foregroundStyle(.white.opacity(0.46)) Text(entry.category) .foregroundStyle(Color.celestiaCyan) Text(entry.level.rawValue) .foregroundStyle(color(for: entry.level)) } Text(entry.message) .foregroundStyle(.white.opacity(0.9)) .fixedSize(horizontal: false, vertical: true) } .font(.system(size: 10, design: .monospaced)) .textSelection(.enabled) } private func color(for level: DeveloperLogEntry.Level) -> Color { switch level { case .info: .white.opacity(0.7) case .success: .green case .warning: .yellow case .error: .red case .transmit: .orange case .receive: .mint } } private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "HH:mm:ss" return formatter }() }