DeveloperLogOverlay.swift 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. import SwiftUI
  2. import UIKit
  3. struct DeveloperLogEntry: Identifiable, Equatable {
  4. enum Level: String {
  5. case info = "信息"
  6. case success = "成功"
  7. case warning = "警告"
  8. case error = "错误"
  9. case transmit = "发送"
  10. case receive = "接收"
  11. }
  12. let id = UUID()
  13. let timestamp: Date
  14. let category: String
  15. let level: Level
  16. let message: String
  17. }
  18. final class DeveloperLogStore: ObservableObject, @unchecked Sendable {
  19. static let shared = DeveloperLogStore()
  20. static let enabledKey = "com.celestia.trace.developer_mode"
  21. @Published private(set) var entries: [DeveloperLogEntry] = []
  22. private init() {}
  23. static func log(
  24. _ category: String,
  25. _ message: String,
  26. level: DeveloperLogEntry.Level = .info
  27. ) {
  28. guard UserDefaults.standard.bool(forKey: enabledKey) else { return }
  29. let entry = DeveloperLogEntry(
  30. timestamp: Date(),
  31. category: category,
  32. level: level,
  33. message: message
  34. )
  35. DispatchQueue.main.async {
  36. shared.entries.append(entry)
  37. if shared.entries.count > 500 {
  38. shared.entries.removeFirst(shared.entries.count - 500)
  39. }
  40. }
  41. }
  42. func clear() {
  43. entries.removeAll()
  44. }
  45. var copyableText: String {
  46. entries.map { entry in
  47. "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.category)] [\(entry.level.rawValue)] \(entry.message)"
  48. }
  49. .joined(separator: "\n")
  50. }
  51. private static let timestampFormatter: DateFormatter = {
  52. let formatter = DateFormatter()
  53. formatter.locale = Locale(identifier: "zh_CN")
  54. formatter.dateFormat = "HH:mm:ss.SSS"
  55. return formatter
  56. }()
  57. }
  58. struct DeveloperLogOverlay: View {
  59. @ObservedObject private var logStore = DeveloperLogStore.shared
  60. @State private var isCollapsed = false
  61. @State private var dragOffset: CGSize = .zero
  62. @State private var dragStartOffset: CGSize = .zero
  63. var body: some View {
  64. VStack(spacing: 0) {
  65. header
  66. if !isCollapsed {
  67. Divider()
  68. .overlay(Color.white.opacity(0.12))
  69. ScrollViewReader { proxy in
  70. ScrollView {
  71. LazyVStack(alignment: .leading, spacing: 7) {
  72. if logStore.entries.isEmpty {
  73. Text("等待蓝牙、同步及 App 运行日志…")
  74. .foregroundStyle(.white.opacity(0.55))
  75. } else {
  76. ForEach(logStore.entries) { entry in
  77. logRow(entry)
  78. .id(entry.id)
  79. }
  80. }
  81. }
  82. .frame(maxWidth: .infinity, alignment: .leading)
  83. .padding(10)
  84. }
  85. .frame(height: 210)
  86. .onChange(of: logStore.entries.last?.id) { _, id in
  87. guard let id else { return }
  88. withAnimation(.easeOut(duration: 0.15)) {
  89. proxy.scrollTo(id, anchor: .bottom)
  90. }
  91. }
  92. }
  93. }
  94. }
  95. .containerRelativeFrame(.horizontal) { availableWidth, _ in
  96. min(max(availableWidth - 24, 240), 340)
  97. }
  98. .background(.black.opacity(0.88), in: RoundedRectangle(cornerRadius: 12))
  99. .overlay {
  100. RoundedRectangle(cornerRadius: 12)
  101. .stroke(Color.celestiaCyan.opacity(0.65), lineWidth: 1)
  102. }
  103. .shadow(color: .black.opacity(0.35), radius: 12, y: 5)
  104. .offset(dragOffset)
  105. .padding(.horizontal, 12)
  106. .padding(.bottom, 62)
  107. .accessibilityElement(children: .contain)
  108. .accessibilityLabel("开发者日志悬浮框")
  109. }
  110. private var header: some View {
  111. HStack(spacing: 10) {
  112. Circle()
  113. .fill(Color.green)
  114. .frame(width: 7, height: 7)
  115. Text("开发者日志")
  116. .font(.system(size: 13, weight: .semibold, design: .monospaced))
  117. .foregroundStyle(.white)
  118. Spacer()
  119. Text("\(logStore.entries.count)")
  120. .font(.system(size: 11, design: .monospaced))
  121. .foregroundStyle(.white.opacity(0.55))
  122. Button {
  123. UIPasteboard.general.string = logStore.copyableText
  124. } label: {
  125. Image(systemName: "doc.on.doc")
  126. }
  127. .disabled(logStore.entries.isEmpty)
  128. Button {
  129. logStore.clear()
  130. } label: {
  131. Image(systemName: "trash")
  132. }
  133. .disabled(logStore.entries.isEmpty)
  134. Button {
  135. withAnimation(.easeInOut(duration: 0.18)) {
  136. isCollapsed.toggle()
  137. }
  138. } label: {
  139. Image(systemName: isCollapsed ? "chevron.up" : "chevron.down")
  140. }
  141. }
  142. .font(.system(size: 12, weight: .medium))
  143. .foregroundStyle(Color.celestiaCyan)
  144. .padding(.horizontal, 11)
  145. .frame(height: 40)
  146. .contentShape(Rectangle())
  147. .gesture(
  148. DragGesture()
  149. .onChanged { value in
  150. dragOffset = CGSize(
  151. width: dragStartOffset.width + value.translation.width,
  152. height: dragStartOffset.height + value.translation.height
  153. )
  154. }
  155. .onEnded { _ in
  156. dragStartOffset = dragOffset
  157. }
  158. )
  159. }
  160. private func logRow(_ entry: DeveloperLogEntry) -> some View {
  161. VStack(alignment: .leading, spacing: 2) {
  162. HStack(spacing: 5) {
  163. Text(Self.timeFormatter.string(from: entry.timestamp))
  164. .foregroundStyle(.white.opacity(0.46))
  165. Text(entry.category)
  166. .foregroundStyle(Color.celestiaCyan)
  167. Text(entry.level.rawValue)
  168. .foregroundStyle(color(for: entry.level))
  169. }
  170. Text(entry.message)
  171. .foregroundStyle(.white.opacity(0.9))
  172. .fixedSize(horizontal: false, vertical: true)
  173. }
  174. .font(.system(size: 10, design: .monospaced))
  175. .textSelection(.enabled)
  176. }
  177. private func color(for level: DeveloperLogEntry.Level) -> Color {
  178. switch level {
  179. case .info: .white.opacity(0.7)
  180. case .success: .green
  181. case .warning: .yellow
  182. case .error: .red
  183. case .transmit: .orange
  184. case .receive: .mint
  185. }
  186. }
  187. private static let timeFormatter: DateFormatter = {
  188. let formatter = DateFormatter()
  189. formatter.locale = Locale(identifier: "zh_CN")
  190. formatter.dateFormat = "HH:mm:ss"
  191. return formatter
  192. }()
  193. }