Parcourir la source

feat(developer): 增加开发者诊断日志浮层 (DeveloperLogOverlay) 与蓝牙/网络日志搜集

bob.yuxinyang il y a 1 mois
Parent
commit
d9ca9900a3

+ 44 - 0
CelestiaTrace/Services/Bluetooth/BLEManager.swift

@@ -278,6 +278,7 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
     // MARK: - Scanning and binding
 
     func startScanning() {
+        DeveloperLogStore.log("蓝牙", "开始搜索附近 BLE 设备")
         discoveredDevices.removeAll()
         observedPeripheralIDs.removeAll()
         observedPeripheralCount = 0
@@ -291,6 +292,11 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
             // Keep the request pending while CoreBluetooth initializes. The
             // delegate starts scanning if the state later becomes powered on.
             isScanning = false
+            DeveloperLogStore.log(
+                "蓝牙",
+                "扫描请求等待系统蓝牙可用,当前状态:\(centralManager.state.developerDescription)",
+                level: .warning
+            )
             return
         }
 
@@ -305,6 +311,7 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
     }
 
     func stopScanning() {
+        DeveloperLogStore.log("蓝牙", "停止搜索,累计发现 \(observedPeripheralCount) 个设备")
         scanRequested = false
         advertisementRefreshTimer?.invalidate()
         advertisementRefreshTimer = nil
@@ -422,6 +429,11 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
 
     func centralManagerDidUpdateState(_ central: CBCentralManager) {
         state = central.state
+        DeveloperLogStore.log(
+            "蓝牙",
+            "系统蓝牙状态变为:\(central.state.developerDescription)",
+            level: central.state == .poweredOn ? .success : .warning
+        )
         guard central.state == .poweredOn else {
             isScanning = false
             markAllDevicesDisconnected()
@@ -487,6 +499,10 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
             if hadDisappeared {
                 existing.reappearanceCount += 1
                 existing.lastTransitionAt = now
+                DeveloperLogStore.log(
+                    "蓝牙扫描",
+                    "\(displayName) 重新出现,RSSI \(RSSI.intValue) dBm,服务 \(serviceUUIDStrings.isEmpty ? "无" : serviceUUIDStrings.joined(separator: ","))"
+                )
             }
             observedAdvertisementsByID[id] = existing
         } else if RSSI.intValue >= Self.debugMinimumRSSI {
@@ -502,6 +518,11 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
                 isPresent: true,
                 reappearanceCount: 0
             )
+            DeveloperLogStore.log(
+                "蓝牙扫描",
+                "发现 \(displayName),RSSI \(RSSI.intValue) dBm,服务 \(serviceUUIDStrings.isEmpty ? "无" : serviceUUIDStrings.joined(separator: ","))",
+                level: Self.isSparkAdvertisement(name: advertisedName, serviceUUIDs: advertisedServices) ? .success : .info
+            )
         }
         publishObservedAdvertisements(now: now)
 
@@ -1118,6 +1139,15 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         if communicationLogs.count > 300 {
             communicationLogs.removeFirst(communicationLogs.count - 300)
         }
+        let level: DeveloperLogEntry.Level
+        switch kind {
+        case "OK": level = .success
+        case "ERROR": level = .error
+        case "TX": level = .transmit
+        case "RX": level = .receive
+        default: level = .info
+        }
+        DeveloperLogStore.log("蓝牙通信", "\(deviceID.prefix(8)) · \(message)", level: level)
     }
 
     private func currentTimeCommand() -> String {
@@ -1268,6 +1298,20 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
     }
 }
 
+private extension CBManagerState {
+    var developerDescription: String {
+        switch self {
+        case .unknown: "未知"
+        case .resetting: "正在重置"
+        case .unsupported: "设备不支持"
+        case .unauthorized: "未授权"
+        case .poweredOff: "已关闭"
+        case .poweredOn: "可用"
+        @unknown default: "其他(\(rawValue))"
+        }
+    }
+}
+
 // MARK: - Spark audio recorder
 
 /// Records the MP3 notification stream produced by a bound Spark device.

+ 182 - 19
CelestiaTrace/Services/Network/RemoteNetworkService.swift

@@ -36,6 +36,10 @@ struct RemoteEvent: Decodable {
     let textContent: String?
     let voiceStartOffsetMs: Int64?
     let voiceEndOffsetMs: Int64?
+    let locationName: String?
+    let locationAddress: String?
+    let latitude: Double?
+    let longitude: Double?
 }
 
 struct RemoteSession: Decodable {
@@ -69,6 +73,10 @@ private struct EventUpload: Encodable {
     let textContent: String?
     let voiceStartOffsetMs: Int64?
     let voiceEndOffsetMs: Int64?
+    let locationName: String?
+    let locationAddress: String?
+    let latitude: Double?
+    let longitude: Double?
 }
 
 private struct SyncCheckpoint: Decodable {
@@ -151,7 +159,11 @@ final class RemoteNetworkService: NetworkServiceProtocol {
                     eventType: $0.eventType,
                     textContent: $0.textContent,
                     voiceStartOffsetMs: $0.voiceStartOffsetMs,
-                    voiceEndOffsetMs: $0.voiceEndOffsetMs
+                    voiceEndOffsetMs: $0.voiceEndOffsetMs,
+                    locationName: $0.locationName,
+                    locationAddress: $0.locationAddress,
+                    latitude: $0.latitude,
+                    longitude: $0.longitude
                 )
             },
             baseRevision: session.serverRevision > 0 ? session.serverRevision : nil,
@@ -505,7 +517,11 @@ final class SyncManager: ObservableObject {
         modelContext: ModelContext,
         userID: String
     ) async -> Bool {
-        guard !isSyncing else { return false }
+        guard !isSyncing else {
+            DeveloperLogStore.log("现场记录同步", "已有同步任务正在运行,忽略重复请求", level: .warning)
+            return false
+        }
+        DeveloperLogStore.log("现场记录同步", "开始同步,本地共 \(sessions.count) 条记录")
         isSyncing = true
         let task = Task { @MainActor [self] in
             await performSync(
@@ -523,6 +539,11 @@ final class SyncManager: ObservableObject {
         activeSyncTask = nil
         isSyncing = false
         activeSessionID = nil
+        DeveloperLogStore.log(
+            "现场记录同步",
+            result ? "同步任务完成" : "同步任务未完成",
+            level: result ? .success : .warning
+        )
         return result
     }
 
@@ -540,33 +561,48 @@ final class SyncManager: ObservableObject {
         completedCount = 0
         activeSessionID = nil
         syncProgress = 0
-        let queuedSessions = sessions.filter {
+        let queuedSessions = deduplicatedSessions(sessions.filter {
             ($0.ownerUserID == nil || $0.ownerUserID == userID)
                 && $0.needsCloudSync
                 && $0.syncState != .conflict
-        }
+        })
         totalCount = queuedSessions.count
+        DeveloperLogStore.log("现场记录同步", "待上传 \(queuedSessions.count) 条记录,正在获取云端索引")
         // Give the UI immediate feedback while the remote index is being fetched.
         // The persisted state is changed only when this session actually begins syncing.
         activeSessionID = queuedSessions.first?.id
         do {
             let remoteSessions = try await service.fetchSessions()
+            DeveloperLogStore.log("现场记录同步", "获取到 \(remoteSessions.count) 条云端记录", level: .success)
             try Task.checkCancellation()
-            let mergedSessions = merge(remoteSessions, into: sessions, modelContext: modelContext, userID: userID)
+            // Detail and post-recording syncs may upload only one session, while
+            // fetchSessions always returns the complete cloud history. Use the
+            // complete local store as the merge baseline to avoid inserting the
+            // other cloud sessions again with duplicate client IDs.
+            let allLocalSessions = try modelContext.fetch(FetchDescriptor<CelestiaSession>())
+            let mergedSessions = try await merge(
+                remoteSessions,
+                into: allLocalSessions,
+                modelContext: modelContext,
+                userID: userID
+            )
             for (remote, local) in mergedSessions {
                 try await downloadMissingAssets(for: local, remote: remote)
                 try Task.checkCancellation()
+                await Task.yield()
             }
             try modelContext.save()
 
-            let eligible = sessions.filter {
+            let eligible = deduplicatedSessions(sessions.filter {
                 ($0.ownerUserID == nil || $0.ownerUserID == userID)
                     && $0.needsCloudSync
                     && $0.syncState != .conflict
-            }
+            })
             var failures: [String] = []
             let eligibleCount = max(eligible.count, 1)
             for (index, session) in eligible.enumerated() {
+                try Task.checkCancellation()
+                await Task.yield()
                 activeSessionID = session.id
                 let sessionStartProgress = Double(index) / Double(eligibleCount)
                 let sessionProgressSpan = 1.0 / Double(eligibleCount)
@@ -574,6 +610,10 @@ final class SyncManager: ObservableObject {
                 session.ownerUserID = userID
                 session.syncState = .syncing
                 session.lastSyncError = nil
+                DeveloperLogStore.log(
+                    "现场记录同步",
+                    "正在同步 \(index + 1)/\(eligible.count):\(session.title)"
+                )
                 do {
                     let previousRemote = remoteSessions.first {
                         $0.id == session.cloudSessionId
@@ -603,6 +643,11 @@ final class SyncManager: ObservableObject {
                     session.lastSyncedAt = Date()
                     completedCount += 1
                     syncProgress = sessionStartProgress + sessionProgressSpan
+                    DeveloperLogStore.log(
+                        "现场记录同步",
+                        "\(session.title) 同步成功,云端版本 v\(session.serverRevision)",
+                        level: .success
+                    )
                 } catch where Task.isCancelled {
                     session.isSynced = false
                     session.syncState = .pending
@@ -614,11 +659,21 @@ final class SyncManager: ObservableObject {
                     session.isSynced = false
                     session.syncState = .conflict
                     session.lastSyncError = "本地版本 \(session.serverRevision) 与云端版本不一致"
+                    DeveloperLogStore.log(
+                        "现场记录同步",
+                        "\(session.title) 发生版本冲突",
+                        level: .error
+                    )
                 } catch {
                     session.isSynced = false
                     session.syncState = .failed
                     session.lastSyncError = error.localizedDescription
                     failures.append("\(session.title):\(error.localizedDescription)")
+                    DeveloperLogStore.log(
+                        "现场记录同步",
+                        "\(session.title) 同步失败:\(error.localizedDescription)",
+                        level: .error
+                    )
                 }
                 try modelContext.save()
             }
@@ -630,18 +685,21 @@ final class SyncManager: ObservableObject {
             }
             guard failures.isEmpty else {
                 lastErrorMessage = failures.joined(separator: "\n")
+                DeveloperLogStore.log("现场记录同步", "同步结束,存在 \(failures.count) 个问题", level: .error)
                 return false
             }
             try await service.recordSyncCheckpoint()
             let now = Date()
             lastSyncDate = now
             UserDefaults.standard.set(now, forKey: "com.celestia.trace.last_server_sync")
+            DeveloperLogStore.log("现场记录同步", "云端检查点已更新", level: .success)
             return true
         } catch where Task.isCancelled {
             syncProgress = 0
             return false
         } catch {
             lastErrorMessage = error.localizedDescription
+            DeveloperLogStore.log("现场记录同步", "同步中断:\(error.localizedDescription)", level: .error)
             return false
         }
     }
@@ -759,9 +817,24 @@ final class SyncManager: ObservableObject {
             }
         }
 
-        let photoAssets = Dictionary(uniqueKeysWithValues: assets
-            .filter { $0.kind.uppercased() == "PHOTO" }
-            .map { ($0.clientId.lowercased(), $0) })
+        let photoAssets = Dictionary(
+            assets
+                .filter { $0.kind.uppercased() == "PHOTO" }
+                .map { ($0.clientId.lowercased(), $0) },
+            uniquingKeysWith: { current, candidate in
+                (candidate.createdAt ?? .distantPast) > (current.createdAt ?? .distantPast)
+                    ? candidate
+                    : current
+                }
+        )
+        let remotePhotoCount = assets.lazy.filter { $0.kind.uppercased() == "PHOTO" }.count
+        if photoAssets.count < remotePhotoCount {
+            DeveloperLogStore.log(
+                "现场记录同步",
+                "云端照片资源存在重复 clientId,已选择最新资源继续同步",
+                level: .warning
+            )
+        }
         for event in session.events where event.eventType == "PHOTO" {
             guard event.localFilePath == nil || AudioPathHelper.resolveURL(for: event.localFilePath) == nil else { continue }
             let key = "\(event.id.uuidString)-photo".lowercased()
@@ -784,14 +857,38 @@ final class SyncManager: ObservableObject {
         into localSessions: [CelestiaSession],
         modelContext: ModelContext,
         userID: String
-    ) -> [(RemoteSession, CelestiaSession)] {
-        var byClientID = Dictionary(uniqueKeysWithValues: localSessions.map { ($0.id.uuidString.lowercased(), $0) })
+    ) async throws -> [(RemoteSession, CelestiaSession)] {
+        var byClientID = Dictionary(
+            localSessions.map { ($0.id.uuidString.lowercased(), $0) },
+            uniquingKeysWith: preferredLocalSession
+        )
+        if byClientID.count < localSessions.count {
+            DeveloperLogStore.log(
+                "现场记录同步",
+                "检测到 \(localSessions.count - byClientID.count) 条重复本地记录,已选择云端版本较新的记录继续同步",
+                level: .warning
+            )
+        }
+        let activeRemoteSessions = remoteSessions.filter { $0.deletedAt == nil }
+        let activeRemoteIDs = Set(activeRemoteSessions.map(\.id))
+        let activeRemoteClientIDs = Set(activeRemoteSessions.compactMap { $0.clientId?.lowercased() })
+        var deletedLocalObjects: Set<ObjectIdentifier> = []
+        var deletedFileURLs: Set<URL> = []
         var merged: [(RemoteSession, CelestiaSession)] = []
-        for remote in remoteSessions {
+        for (index, remote) in remoteSessions.enumerated() {
+            if index.isMultiple(of: 20) {
+                await Task.yield()
+            }
             guard let clientID = remote.clientId?.lowercased() else { continue }
             if let local = byClientID[clientID] {
-                if remote.deletedAt != nil, local.isSynced {
+                if remote.deletedAt != nil, local.hasConfirmedCloudSync {
+                    deletedFileURLs.formUnion(localFileURLs(for: local))
+                    deletedLocalObjects.insert(ObjectIdentifier(local))
                     modelContext.delete(local)
+                    DeveloperLogStore.log(
+                        "现场记录同步",
+                        "云端记录已删除,同步移除本地记录:\(local.title)"
+                    )
                     continue
                 }
                 if !local.isSynced,
@@ -801,7 +898,7 @@ final class SyncManager: ObservableObject {
                     continue
                 }
                 if local.isSynced, remote.revision > local.serverRevision {
-                    apply(remote, to: local, userID: userID)
+                    await apply(remote, to: local, userID: userID)
                 }
                 if remote.deletedAt == nil {
                     merged.append((remote, local))
@@ -809,17 +906,76 @@ final class SyncManager: ObservableObject {
             } else if remote.deletedAt == nil {
                 let id = UUID(uuidString: clientID) ?? UUID()
                 let session = CelestiaSession(id: id, title: remote.title, startTime: remote.startTime)
-                apply(remote, to: session, userID: userID)
+                await apply(remote, to: session, userID: userID)
                 modelContext.insert(session)
                 byClientID[clientID] = session
                 merged.append((remote, session))
             }
         }
-        try? modelContext.save()
+
+        // Some servers may permanently remove a record instead of returning a
+        // tombstone. A previously confirmed cloud record that is absent by both
+        // server ID and client ID should mirror that deletion locally. Records
+        // with pending/failed/conflicting local content are deliberately kept.
+        for (index, local) in localSessions.enumerated() {
+            if index.isMultiple(of: 20) {
+                await Task.yield()
+            }
+            guard !deletedLocalObjects.contains(ObjectIdentifier(local)),
+                  local.ownerUserID == nil || local.ownerUserID == userID,
+                  local.hasConfirmedCloudSync else { continue }
+            let existsRemotely =
+                local.cloudSessionId.map(activeRemoteIDs.contains) == true
+                || activeRemoteClientIDs.contains(local.id.uuidString.lowercased())
+            guard !existsRemotely else { continue }
+
+            deletedFileURLs.formUnion(localFileURLs(for: local))
+            deletedLocalObjects.insert(ObjectIdentifier(local))
+            modelContext.delete(local)
+            DeveloperLogStore.log(
+                "现场记录同步",
+                "云端不存在已同步记录,同步移除本地记录:\(local.title)"
+            )
+        }
+
+        try modelContext.save()
+        for fileURL in deletedFileURLs {
+            try? FileManager.default.removeItem(at: fileURL)
+        }
         return merged
     }
 
-    private func apply(_ remote: RemoteSession, to local: CelestiaSession, userID: String) {
+    private func localFileURLs(for session: CelestiaSession) -> Set<URL> {
+        let storedPaths =
+            [session.localAudioPath].compactMap { $0 }
+            + session.audioChunks.map(\.localFilePath)
+            + session.events.compactMap(\.localFilePath)
+        return Set(storedPaths.compactMap { AudioPathHelper.resolveURL(for: $0) })
+    }
+
+    private func deduplicatedSessions(_ sessions: [CelestiaSession]) -> [CelestiaSession] {
+        Array(Dictionary(
+            sessions.map { ($0.id, $0) },
+            uniquingKeysWith: preferredLocalSession
+        ).values)
+    }
+
+    private func preferredLocalSession(
+        _ current: CelestiaSession,
+        _ candidate: CelestiaSession
+    ) -> CelestiaSession {
+        if (current.cloudSessionId == nil) != (candidate.cloudSessionId == nil) {
+            return current.cloudSessionId == nil ? candidate : current
+        }
+        if current.serverRevision != candidate.serverRevision {
+            return current.serverRevision > candidate.serverRevision ? current : candidate
+        }
+        return (current.lastSyncedAt ?? .distantPast) >= (candidate.lastSyncedAt ?? .distantPast)
+            ? current
+            : candidate
+    }
+
+    private func apply(_ remote: RemoteSession, to local: CelestiaSession, userID: String) async {
         local.title = remote.title
         local.startTime = remote.startTime
         local.endTime = remote.endTime
@@ -830,12 +986,19 @@ final class SyncManager: ObservableObject {
         local.syncState = .synced
         local.lastSyncError = nil
         local.events.removeAll()
-        for remoteEvent in remote.events ?? [] {
+        for (index, remoteEvent) in (remote.events ?? []).enumerated() {
+            if index.isMultiple(of: 50) {
+                await Task.yield()
+            }
             let id = remoteEvent.clientId.flatMap(UUID.init(uuidString:)) ?? UUID()
             let event = CelestiaTimelineEvent(id: id, relativeTimeMs: remoteEvent.relativeTimeMs, eventType: remoteEvent.eventType)
             event.textContent = remoteEvent.textContent
             event.voiceStartOffsetMs = remoteEvent.voiceStartOffsetMs
             event.voiceEndOffsetMs = remoteEvent.voiceEndOffsetMs
+            event.locationName = remoteEvent.locationName
+            event.locationAddress = remoteEvent.locationAddress
+            event.latitude = remoteEvent.latitude
+            event.longitude = remoteEvent.longitude
             local.events.append(event)
         }
     }

+ 215 - 0
CelestiaTrace/Views/Components/DeveloperLogOverlay.swift

@@ -0,0 +1,215 @@
+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
+    }()
+}

+ 33 - 45
CelestiaTrace/Views/Components/PulsingRecordButton.swift

@@ -10,50 +10,41 @@ struct PulsingRecordButton: View {
     /// Closure triggered on tap.
     let action: () -> Void
 
-    /// Controls the subtle pulse animation.
-    @State private var isAnimating: Bool = false
-
     var body: some View {
-        ZStack {
-            // Precise outer thin outline (pulses slightly)
-            Circle()
-                .stroke(Color.primary.opacity(0.1), lineWidth: 1)
-                .frame(width: 120, height: 120)
-                .scaleEffect(isAnimating ? 1.08 : 0.98)
-                .animation(
-                    .easeInOut(duration: 2.0).repeatForever(autoreverses: true),
-                    value: isAnimating
-                )
-            
-            // Middle thin outline (expanding and fading pulse)
-            Circle()
-                .stroke(Color.primary.opacity(0.08), lineWidth: 1)
-                .frame(width: 100, height: 100)
-                .scaleEffect(isAnimating ? 1.25 : 1.0)
-                .opacity(isAnimating ? 0.0 : 0.8)
-                .animation(
-                    .easeOut(duration: 2.5).repeatForever(autoreverses: false),
-                    value: isAnimating
-                )
+        TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
+            let time = context.date.timeIntervalSinceReferenceDate
+            let breathingPhase = (sin(time * .pi) + 1) / 2
+            let ripplePhase = time.truncatingRemainder(dividingBy: 2.5) / 2.5
+
+            ZStack {
+                // These transforms are derived directly from time instead of
+                // using a repeating implicit animation. That keeps SwiftUI from
+                // accidentally animating parent layout changes after a cover or
+                // app lifecycle transition.
+                Circle()
+                    .stroke(Color.primary.opacity(0.1), lineWidth: 1)
+                    .frame(width: 120, height: 120)
+                    .scaleEffect(0.98 + breathingPhase * 0.10)
+
+                Circle()
+                    .stroke(Color.primary.opacity(0.08), lineWidth: 1)
+                    .frame(width: 100, height: 100)
+                    .scaleEffect(1.0 + ripplePhase * 0.25)
+                    .opacity(0.8 * (1.0 - ripplePhase))
 
-            // Inner button body - pure line circle with translucent background
-            Circle()
-                .fill(Color.primary.opacity(0.03))
-                .frame(width: 90, height: 90)
-                .overlay(
-                    Circle()
-                        .stroke(Color.primary.opacity(0.2), lineWidth: 1)
-                )
-                
-            // Inner core indicator - simple line microphone icon
-            Image(systemName: "mic")
-                .font(.system(size: 24, weight: .light))
-                .foregroundColor(.primary)
-                .scaleEffect(isAnimating ? 1.02 : 0.98)
-                .animation(
-                    .easeInOut(duration: 2.0).repeatForever(autoreverses: true),
-                    value: isAnimating
-                )
+                Circle()
+                    .fill(Color.primary.opacity(0.03))
+                    .frame(width: 90, height: 90)
+                    .overlay(
+                        Circle()
+                            .stroke(Color.primary.opacity(0.2), lineWidth: 1)
+                    )
+
+                Image(systemName: "mic")
+                    .font(.system(size: 24, weight: .light))
+                    .foregroundColor(.primary)
+                    .scaleEffect(0.98 + breathingPhase * 0.04)
+            }
         }
         .frame(width: 120, height: 120)
         .contentShape(Circle())
@@ -61,9 +52,6 @@ struct PulsingRecordButton: View {
             HapticManager.trigger(.recordStart)
             action()
         }
-        .onAppear {
-            isAnimating = true
-        }
     }
 }
 

+ 71 - 21
CelestiaTrace/Views/ContentView.swift

@@ -4,41 +4,94 @@ import SwiftData
 // MARK: - ContentView
 
 /// Root navigation container for the CelestiaTrace app.
-/// Uses a TabView with three tabs: Home, History, and Settings.
+/// Uses a TabView with three tabs: Home, Field Records, and Settings.
 struct ContentView: View {
+    @Environment(\.modelContext) private var modelContext
+    @Environment(\.scenePhase) private var scenePhase
+    @Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession]
+    @ObservedObject private var authManager = AuthManager.shared
+    @AppStorage(DeveloperLogStore.enabledKey) private var isDeveloperModeEnabled = false
 
     /// Currently selected tab.
     @State private var selectedTab: Tab = .home
+    /// Session selected for detail playback navigation in Field Records tab.
+    @State private var fieldRecordsSelectedSession: CelestiaSession?
 
     var body: some View {
-        TabView(selection: $selectedTab) {
-            HomeView()
+        ZStack(alignment: .bottomTrailing) {
+            TabView(selection: $selectedTab) {
+                HomeView { session in
+                    fieldRecordsSelectedSession = session
+                    selectedTab = .fieldRecords
+                }
                 .tabItem {
                     Label(Tab.home.title, systemImage: Tab.home.icon)
                 }
                 .tag(Tab.home)
 
-            SessionListView {
-                selectedTab = .home
-            }
+                SessionListView(
+                    selectedSession: $fieldRecordsSelectedSession,
+                    onStartRecording: {
+                        selectedTab = .home
+                    }
+                )
                 .tabItem {
-                    Label(Tab.history.title, systemImage: Tab.history.icon)
+                    Label(Tab.fieldRecords.title, systemImage: Tab.fieldRecords.icon)
                 }
-                .tag(Tab.history)
+                .tag(Tab.fieldRecords)
 
-            SettingsView()
+                SettingsView()
                 .tabItem {
                     Label(Tab.settings.title, systemImage: Tab.settings.icon)
                 }
                 .tag(Tab.settings)
+            }
+            .tint(.celestiaCyan)
 
-            ProfileView()
-                .tabItem {
-                    Label(Tab.profile.title, systemImage: Tab.profile.icon)
-                }
-                .tag(Tab.profile)
+            if isDeveloperModeEnabled {
+                DeveloperLogOverlay()
+                    .transition(.scale(scale: 0.92, anchor: .bottomTrailing).combined(with: .opacity))
+                    .zIndex(20)
+            }
         }
-        .tint(.celestiaCyan)
+        .animation(.easeInOut(duration: 0.2), value: isDeveloperModeEnabled)
+        .onAppear {
+            if isDeveloperModeEnabled {
+                DeveloperLogStore.log("App", "开发者模式已启用,开始记录本次运行日志", level: .success)
+            }
+            if RecordingRecoveryStore.activeSessionID != nil
+                || RecordingLiveActivityManager.shared.recoverableSessionID != nil {
+                selectedTab = .home
+            }
+        }
+        .task(id: authManager.currentUser?.id) {
+            await synchronizeFieldRecords()
+        }
+        .onChange(of: scenePhase) { _, newPhase in
+            guard newPhase == .active else { return }
+            Task {
+                await synchronizeFieldRecords()
+            }
+        }
+        .onOpenURL { url in
+            guard RecordingLiveActivityRoute(url: url) != nil else { return }
+            selectedTab = .home
+        }
+        .onChange(of: isDeveloperModeEnabled) { _, enabled in
+            if enabled {
+                DeveloperLogStore.log("App", "开发者模式已开启", level: .success)
+            }
+        }
+    }
+
+    @MainActor
+    private func synchronizeFieldRecords() async {
+        guard let userID = authManager.currentUser?.id else { return }
+        _ = await SyncManager.shared.sync(
+            sessions: sessions,
+            modelContext: modelContext,
+            userID: userID
+        )
     }
 }
 
@@ -48,25 +101,22 @@ extension ContentView {
     /// Defines the available tabs in the app's root navigation.
     enum Tab: Hashable {
         case home
-        case history
+        case fieldRecords
         case settings
-        case profile
 
         var title: String {
             switch self {
             case .home: "首页"
-            case .history: "历史记录"
+            case .fieldRecords: "现场记录"
             case .settings: "设置"
-            case .profile: "个人中心"
             }
         }
 
         var icon: String {
             switch self {
             case .home: "house.fill"
-            case .history: "clock.arrow.circlepath"
+            case .fieldRecords: "waveform.path.ecg"
             case .settings: "gearshape.fill"
-            case .profile: "person.crop.circle.fill"
             }
         }
     }

+ 207 - 7
CelestiaTrace/Views/Home/HomeView.swift

@@ -1,5 +1,6 @@
 import SwiftUI
 import SwiftData
+import AVFoundation
 
 // MARK: - HomeView
 /// The main launch screen of CelestiaTrace.
@@ -9,15 +10,18 @@ struct HomeView: View {
     @Environment(\.modelContext) private var modelContext
     @ObservedObject private var bleManager: BLEManager = .shared
     @ObservedObject private var authManager: AuthManager = .shared
+    @Query(sort: \CelestiaSession.startTime, order: .reverse)
+    private var sessions: [CelestiaSession]
 
     @State private var activeRecording: ActiveRecordingPresentation?
     @State private var completedRecordingSession: CelestiaSession?
-    @State private var selectedSessionForDetail: CelestiaSession?
     @State private var detector = RecordingEnvironmentDetector()
     @State private var dismissedWarnings: Set<String> = []
     @State private var showRecordingSourcePicker = false
     @State private var pendingRecordingSource: RecordingSourceChoice?
 
+    var onRecordingFinished: ((CelestiaSession) -> Void)? = nil
+
     var body: some View {
         NavigationStack {
             ZStack {
@@ -53,16 +57,20 @@ struct HomeView: View {
             .navigationBarHidden(true)
             .onAppear {
                 detector.checkEnvironment()
+                restoreActiveRecordingIfNeeded()
             }
+            .onOpenURL(perform: handleRecordingURL)
             .fullScreenCover(item: $activeRecording, onDismiss: {
                 if let session = completedRecordingSession {
-                    selectedSessionForDetail = session
                     completedRecordingSession = nil
+                    onRecordingFinished?(session)
                 }
             }) { recording in
                 ActiveRecordingView(
                     session: recording.session,
-                    recordingSource: recording.source
+                    initialDuration: recording.initialDuration,
+                    recordingSource: recording.source,
+                    initialAction: recording.initialAction
                 ) {
                     completedRecordingSession = recording.session
                 }
@@ -77,9 +85,6 @@ struct HomeView: View {
                     showRecordingSourcePicker = false
                 }
             }
-            .navigationDestination(item: $selectedSessionForDetail) { session in
-                SessionDetailView(session: session)
-            }
         }
     }
 
@@ -166,9 +171,106 @@ struct HomeView: View {
         let session = CelestiaSession(title: title)
         modelContext.insert(session)
         try? modelContext.save()
+        RecordingRecoveryStore.begin(sessionID: session.id, source: source)
 
         HapticManager.trigger(.recordStart)
-        activeRecording = ActiveRecordingPresentation(session: session, source: source)
+        activeRecording = ActiveRecordingPresentation(
+            session: session,
+            source: source,
+            initialDuration: 0,
+            initialAction: nil
+        )
+    }
+
+    private func restoreActiveRecordingIfNeeded(
+        sessionID requestedSessionID: UUID? = nil,
+        action: RecordingLiveActivityAction? = nil
+    ) {
+        guard activeRecording == nil else { return }
+
+        let persistedID = RecordingRecoveryStore.activeSessionID
+        let activityID = RecordingLiveActivityManager.shared.recoverableSessionID
+        let session: CelestiaSession?
+        if let requestedSessionID {
+            // An explicit Live Activity action must never fall through to a
+            // different unfinished recording (especially for the stop action).
+            session = sessions.first {
+                $0.id == requestedSessionID && $0.endTime == nil
+            }
+        } else {
+            session = [persistedID, activityID]
+                .compactMap { $0 }
+                .compactMap { candidateID in
+                    sessions.first {
+                        $0.id == candidateID && $0.endTime == nil
+                    }
+                }
+                .first
+                ?? sessions.first {
+                    $0.endTime == nil
+                        && Date().timeIntervalSince($0.startTime) < 24 * 60 * 60
+                }
+        }
+
+        guard let session else {
+            if requestedSessionID == nil, persistedID != nil {
+                RecordingRecoveryStore.clear()
+            }
+            return
+        }
+
+        let source = RecordingRecoveryStore.source(for: session.id) ?? .iPhone
+        RecordingRecoveryStore.begin(sessionID: session.id, source: source, preservingSegment: true)
+
+        Task { @MainActor in
+            await recoverPendingSegment(for: session)
+            let duration = await recordedDuration(for: session.localAudioPath)
+            guard activeRecording == nil, session.endTime == nil else { return }
+            activeRecording = ActiveRecordingPresentation(
+                session: session,
+                source: source,
+                initialDuration: duration,
+                initialAction: action
+            )
+        }
+    }
+
+    private func recoverPendingSegment(for session: CelestiaSession) async {
+        guard let pendingPath = RecordingRecoveryStore.pendingSegmentPath(for: session.id),
+              let pendingURL = AudioPathHelper.resolveURL(for: pendingPath) else {
+            return
+        }
+
+        let existingURL = AudioPathHelper.resolveURL(for: session.localAudioPath)
+        if existingURL?.standardizedFileURL == pendingURL.standardizedFileURL {
+            RecordingRecoveryStore.clearPendingSegment(for: session.id)
+            return
+        }
+
+        let recoveredURL = await AudioMerger.mergeAudioFiles(
+            firstURL: existingURL,
+            secondURL: pendingURL
+        )
+        session.localAudioPath = AudioPathHelper.relativePath(from: recoveredURL.path)
+        session.markContentModified()
+        try? modelContext.save()
+        RecordingRecoveryStore.clearPendingSegment(for: session.id)
+    }
+
+    private func recordedDuration(for path: String?) async -> TimeInterval {
+        guard let url = AudioPathHelper.resolveURL(for: path) else { return 0 }
+        let asset = AVURLAsset(url: url)
+        guard let duration = try? await asset.load(.duration) else { return 0 }
+        let seconds = duration.seconds
+        return seconds.isFinite && seconds > 0 ? seconds : 0
+    }
+
+    private func handleRecordingURL(_ url: URL) {
+        guard let route = RecordingLiveActivityRoute(url: url) else { return }
+        restoreActiveRecordingIfNeeded(
+            sessionID: route.sessionID,
+            action: route.action
+        )
     }
 
     private var connectedSparkDevices: [BoundDevice] {
@@ -191,6 +293,104 @@ private struct ActiveRecordingPresentation: Identifiable {
     let id = UUID()
     let session: CelestiaSession
     let source: RecordingSourceChoice
+    let initialDuration: TimeInterval
+    let initialAction: RecordingLiveActivityAction?
+}
+
+enum RecordingLiveActivityAction: String {
+    case photo
+    case note
+    case togglePause = "toggle-pause"
+    case stop
+}
+
+struct RecordingLiveActivityRoute {
+    let sessionID: UUID
+    let action: RecordingLiveActivityAction?
+
+    init?(url: URL) {
+        guard url.scheme == "celestiatrace", url.host == "recording" else { return nil }
+        let components = url.pathComponents.filter { $0 != "/" }
+        guard let first = components.first, let sessionID = UUID(uuidString: first) else {
+            return nil
+        }
+        self.sessionID = sessionID
+        self.action = components.dropFirst().first.flatMap(RecordingLiveActivityAction.init(rawValue:))
+    }
+}
+
+enum RecordingRecoveryStore {
+    private static let defaults = UserDefaults.standard
+    private static let sessionIDKey = "activeRecording.sessionID"
+    private static let sourceKindKey = "activeRecording.sourceKind"
+    private static let sourceDeviceIDKey = "activeRecording.sourceDeviceID"
+    private static let sourceNameKey = "activeRecording.sourceName"
+    private static let pendingSegmentPathKey = "activeRecording.pendingSegmentPath"
+
+    static var activeSessionID: UUID? {
+        defaults.string(forKey: sessionIDKey).flatMap(UUID.init(uuidString:))
+    }
+
+    static func begin(
+        sessionID: UUID,
+        source: RecordingSourceChoice,
+        preservingSegment: Bool = false
+    ) {
+        if !preservingSegment || activeSessionID != sessionID {
+            defaults.removeObject(forKey: pendingSegmentPathKey)
+        }
+        defaults.set(sessionID.uuidString, forKey: sessionIDKey)
+        switch source {
+        case .iPhone:
+            defaults.set("iphone", forKey: sourceKindKey)
+            defaults.removeObject(forKey: sourceDeviceIDKey)
+            defaults.removeObject(forKey: sourceNameKey)
+        case .spark(let deviceID, let displayName):
+            defaults.set("spark", forKey: sourceKindKey)
+            defaults.set(deviceID, forKey: sourceDeviceIDKey)
+            defaults.set(displayName, forKey: sourceNameKey)
+        }
+    }
+
+    static func source(for sessionID: UUID) -> RecordingSourceChoice? {
+        guard activeSessionID == sessionID else { return nil }
+        switch defaults.string(forKey: sourceKindKey) {
+        case "iphone":
+            return .iPhone
+        case "spark":
+            guard let deviceID = defaults.string(forKey: sourceDeviceIDKey),
+                  let name = defaults.string(forKey: sourceNameKey) else { return nil }
+            return .spark(deviceID: deviceID, displayName: name)
+        default:
+            return nil
+        }
+    }
+
+    static func setPendingSegment(_ url: URL, for sessionID: UUID) {
+        guard activeSessionID == sessionID else { return }
+        defaults.set(AudioPathHelper.relativePath(from: url.path), forKey: pendingSegmentPathKey)
+    }
+
+    static func pendingSegmentPath(for sessionID: UUID) -> String? {
+        guard activeSessionID == sessionID else { return nil }
+        return defaults.string(forKey: pendingSegmentPathKey)
+    }
+
+    static func clearPendingSegment(for sessionID: UUID) {
+        guard activeSessionID == sessionID else { return }
+        defaults.removeObject(forKey: pendingSegmentPathKey)
+    }
+
+    static func clear(sessionID: UUID? = nil) {
+        if let sessionID, activeSessionID != sessionID { return }
+        [
+            sessionIDKey,
+            sourceKindKey,
+            sourceDeviceIDKey,
+            sourceNameKey,
+            pendingSegmentPathKey
+        ].forEach(defaults.removeObject(forKey:))
+    }
 }
 
 // MARK: - Recording source picker

+ 119 - 273
CelestiaTrace/Views/Profile/ProfileView.swift

@@ -1,84 +1,51 @@
 import SwiftUI
-import SwiftData
 
-/// Main "我" (Mine) Tab View for user authentication, profile management, cloud sync status, and BLE device management.
+/// Account details opened from Settings.
 struct ProfileView: View {
     @ObservedObject var authManager: AuthManager = .shared
-    @ObservedObject var bleManager: BLEManager = .shared
-    @ObservedObject private var syncManager: SyncManager = .shared
-    @Environment(\.modelContext) private var modelContext
-    @Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession]
     
     @State private var showAuthModal = false
-    @State private var showEditProfileModal = false
-    @State private var showChangePasswordModal = false
-    @State private var showScanDeviceModal = false
-    @State private var showSyncAuthPrompt = false
     @State private var showLogoutConfirmation = false
-    @State private var syncToastMessage: String?
+    @State private var usernameInput = ""
+    @State private var emailInput = ""
+    @State private var phoneInput = ""
+    @State private var profileMessage: String?
+    @State private var profileMessageIsError = false
     
     var body: some View {
-        NavigationStack {
-            ZStack {
-                Color.spaceBlack.ignoresSafeArea()
+        ZStack {
+            Color.spaceBlack.ignoresSafeArea()
                 
-                ScrollView {
-                    VStack(spacing: 20) {
-                        // 1. Account Profile Header
-                        accountHeaderCard
-                            .padding(.top, 12)
+            ScrollView {
+                VStack(spacing: 20) {
+                    // 1. Account Profile Header
+                    accountHeaderCard
+                        .padding(.top, 12)
                         
-                        // 2. User Management Actions (if logged in)
-                        if authManager.isLoggedIn {
-                            userManagementActionsCard
-                        }
-                        
-                        // 3. Audio Cloud Sync Card
-                        audioSyncCard
-                        
-                        // 4. Device Binding & Management
-                        DeviceListView(
-                            bleManager: bleManager,
-                            authManager: authManager,
-                            onAddDeviceClicked: handleAddDevice
-                        )
-                        
-                        // 5. Account Settings & Security
-                        accountInfoSection
-                        
-                        // Footer
-                        footer
-                            .padding(.top, 12)
-                            .padding(.bottom, 40)
+                    // 2. Inline profile editing (if logged in)
+                    if authManager.isLoggedIn {
+                        profileEditorCard
                     }
-                    .padding(.horizontal, 16)
+
+                    // Footer
+                    footer
+                        .padding(.top, 12)
+                        .padding(.bottom, 40)
                 }
+                .padding(.horizontal, 16)
             }
-            .navigationTitle("个人中心")
-            .navigationBarTitleDisplayMode(.inline)
-            .sheet(isPresented: $showAuthModal) {
-                AuthModalView(authManager: authManager)
-            }
-            .sheet(isPresented: $showEditProfileModal) {
-                EditProfileModalView(authManager: authManager)
-            }
-            .sheet(isPresented: $showChangePasswordModal) {
-                ChangePasswordModalView(authManager: authManager)
-            }
-            .sheet(isPresented: $showScanDeviceModal) {
-                DeviceScanView(bleManager: bleManager, authManager: authManager)
-            }
-            .sheet(isPresented: $showSyncAuthPrompt) {
-                SyncAuthPromptModal(onLoginClick: {
-                    showAuthModal = true
-                })
-            }
-            .confirmationDialog("确定要退出登录吗?", isPresented: $showLogoutConfirmation, titleVisibility: .visible) {
-                Button("退出登录", role: .destructive) {
-                    authManager.logout()
-                }
-                Button("取消", role: .cancel) {}
+        }
+        .navigationTitle("账号")
+        .navigationBarTitleDisplayMode(.inline)
+        .sheet(isPresented: $showAuthModal) {
+            AuthModalView(authManager: authManager)
+        }
+        .onAppear(perform: loadProfileInputs)
+        .confirmationDialog("确定要退出登录吗?", isPresented: $showLogoutConfirmation, titleVisibility: .visible) {
+            Button("退出登录", role: .destructive) {
+                authManager.logout()
             }
+            Button("取消", role: .cancel) {}
         }
     }
     
@@ -100,19 +67,9 @@ struct ProfileView: View {
                     }
                     
                     VStack(alignment: .leading, spacing: 6) {
-                        HStack {
-                            Text(user.username)
-                               .font(.system(size: 18, weight: .bold))
-                                .foregroundStyle(Color.primary)
-                            
-                            Text("云端账号")
-                                .font(.system(size: 9, weight: .bold, design: .monospaced))
-                                .foregroundStyle(Color.spaceBlack)
-                                .padding(.horizontal, 6)
-                                .padding(.vertical, 2)
-                                .background(Color.primary)
-                                .cornerRadius(4)
-                        }
+                        Text(user.username)
+                           .font(.system(size: 18, weight: .bold))
+                            .foregroundStyle(Color.primary)
                         
                         Text(user.email ?? user.phoneNumber ?? ("用户 ID: " + String(user.id.prefix(12))))
                             .font(.system(size: 12))
@@ -181,182 +138,57 @@ struct ProfileView: View {
         .businessBorder(cornerRadius: 12)
     }
     
-    // MARK: - 2. User Management Actions
-    
-    private var userManagementActionsCard: some View {
-        VStack(alignment: .leading, spacing: 14) {
-            HStack(spacing: 8) {
-                Image(systemName: "slider.horizontal.3")
-                    .font(.system(size: 14, weight: .light))
-                    .foregroundStyle(Color.secondary)
-                
-                Text("用户资料管理")
-                    .font(.system(size: 15, weight: .semibold))
-                    .foregroundStyle(Color.primary)
-            }
-            
-            HStack(spacing: 12) {
-                Button {
-                    showEditProfileModal = true
-                } label: {
-                    HStack(spacing: 6) {
-                        Image(systemName: "square.and.pencil")
-                            .font(.system(size: 12))
-                        Text("编辑基本资料")
-                            .font(.system(size: 13, weight: .medium))
-                    }
-                    .foregroundStyle(Color.primary)
-                    .frame(maxWidth: .infinity)
-                    .padding(.vertical, 10)
-                    .background(Color.cardBackground.opacity(0.4))
-                    .cornerRadius(8)
-                    .overlay(
-                        RoundedRectangle(cornerRadius: 8)
-                            .stroke(Color.primary.opacity(0.2), lineWidth: 1)
-                    )
-                }
-                
-                Button {
-                    showChangePasswordModal = true
-                } label: {
-                    HStack(spacing: 6) {
-                        Image(systemName: "key.fill")
-                            .font(.system(size: 12))
-                        Text("修改安全密码")
-                            .font(.system(size: 13, weight: .medium))
-                    }
-                    .foregroundStyle(Color.primary)
-                    .frame(maxWidth: .infinity)
-                    .padding(.vertical, 10)
-                    .background(Color.cardBackground.opacity(0.4))
-                    .cornerRadius(8)
-                    .overlay(
-                        RoundedRectangle(cornerRadius: 8)
-                            .stroke(Color.primary.opacity(0.2), lineWidth: 1)
-                    )
-                }
-            }
-        }
-        .padding(16)
-        .background(Color.cardBackground.opacity(0.25))
-        .businessBorder(cornerRadius: 12)
-    }
-    
-    // MARK: - 3. Audio Cloud Sync Card
+    // MARK: - 2. Inline Profile Editing
     
-    private var audioSyncCard: some View {
+    private var profileEditorCard: some View {
         VStack(alignment: .leading, spacing: 14) {
-            HStack {
-                HStack(spacing: 8) {
-                    Image(systemName: "icloud.and.arrow.up")
-                        .font(.system(size: 14, weight: .light))
-                        .foregroundStyle(Color.secondary)
-                    
-                    Text("录音云端同步")
-                        .font(.system(size: 15, weight: .semibold))
-                        .foregroundStyle(Color.primary)
-                }
-                
-                Spacer()
-                
-                if authManager.isLoggedIn {
-                    HStack(spacing: 4) {
-                        Circle()
-                            .fill(Color.green)
-                            .frame(width: 6, height: 6)
-                        Text("同步功能就绪")
-                            .font(.system(size: 11))
-                            .foregroundStyle(Color.secondary)
-                    }
-                } else {
-                    Text("需要登录")
-                        .font(.system(size: 11))
-                        .foregroundStyle(Color.recordingRed)
-                }
-            }
-            
-            VStack(alignment: .leading, spacing: 8) {
-                HStack {
-                    Text("上次同步时间")
-                        .font(.system(size: 12))
-                        .foregroundStyle(Color.secondary)
-                    Spacer()
-                    if let lastDate = syncManager.lastSyncDate {
-                        Text(lastDate.formatted(date: .numeric, time: .shortened))
-                            .font(.system(size: 12, design: .monospaced))
-                            .foregroundStyle(Color.primary)
-                    } else {
-                        Text("暂无同步记录")
-                            .font(.system(size: 12, design: .monospaced))
-                            .foregroundStyle(Color.secondary)
-                    }
-                }
-                
-                if let toast = syncToastMessage {
-                    HStack(spacing: 6) {
-                        Image(systemName: "checkmark.circle.fill")
-                            .foregroundStyle(Color.primary)
-                        Text(toast)
-                            .font(.system(size: 12))
-                            .foregroundStyle(Color.primary)
-                    }
-                    .padding(.top, 2)
-                }
+            Text("基本资料")
+                .font(.system(size: 15, weight: .semibold))
+                .foregroundStyle(Color.primary)
+
+            profileField(label: "账号名", text: $usernameInput, icon: "person")
+            profileField(label: "电子邮箱", text: $emailInput, icon: "envelope", keyboard: .emailAddress)
+            profileField(label: "手机号码", text: $phoneInput, icon: "phone", keyboard: .phonePad)
+
+            if let profileMessage {
+                Text(profileMessage)
+                    .font(.system(size: 12))
+                    .foregroundStyle(profileMessageIsError ? Color.recordingRed : Color.green)
             }
-            
+
             Button {
-                triggerAudioSync()
+                saveProfile()
             } label: {
-                HStack(spacing: 8) {
-                    if syncManager.isSyncing {
-                        ProgressView()
-                            .tint(Color.primary)
+                Group {
+                    if authManager.isProcessing {
+                        ProgressView().tint(Color.spaceBlack)
                     } else {
-                        Image(systemName: "arrow.triangle.2.circlepath")
-                            .font(.system(size: 13))
-                    }
-                    Text(syncManager.isSyncing ? "正在同步 \(syncManager.completedCount)/\(syncManager.totalCount)..." : "立即同步录音文件")
+                        Text("保存基本资料")
                         .font(.system(size: 13, weight: .medium))
+                    }
                 }
-                .foregroundStyle(Color.primary)
+                .foregroundStyle(Color.spaceBlack)
                 .frame(maxWidth: .infinity)
                 .padding(.vertical, 10)
-                .overlay(
-                    RoundedRectangle(cornerRadius: 8)
-                        .stroke(Color.primary.opacity(0.3), lineWidth: 1)
-                )
+                .background(Color.primary)
+                .cornerRadius(8)
             }
-            .disabled(syncManager.isSyncing)
+            .disabled(authManager.isProcessing)
 
-            if let error = syncManager.lastErrorMessage {
-                Text(error)
-                    .font(.system(size: 11))
-                    .foregroundStyle(Color.recordingRed)
-                    .lineLimit(3)
-            }
-        }
-        .padding(16)
-        .background(Color.cardBackground.opacity(0.25))
-        .businessBorder(cornerRadius: 12)
-    }
-    
-    // MARK: - 5. Account Settings Section
-    
-    private var accountInfoSection: some View {
-        VStack(alignment: .leading, spacing: 14) {
-            HStack(spacing: 8) {
-                Image(systemName: "shield.checkerboard")
-                    .font(.system(size: 14, weight: .light))
-                    .foregroundStyle(Color.secondary)
-                Text("安全与云端合规")
-                    .font(.system(size: 15, weight: .semibold))
-                    .foregroundStyle(Color.primary)
-            }
-            
-            VStack(spacing: 10) {
-                infoRow(label: "数据存储模式", value: "SwiftData 本地优先 + 云端同步")
-                infoRow(label: "登录凭据", value: "iOS Keychain 安全存储")
-                infoRow(label: "传输保护", value: "HTTPS / TLS")
+            NavigationLink {
+                ChangePasswordModalView(authManager: authManager)
+            } label: {
+                HStack {
+                    Image(systemName: "key")
+                    Text("修改安全密码")
+                    Spacer()
+                    Image(systemName: "chevron.right")
+                        .font(.system(size: 11, weight: .semibold))
+                        .foregroundStyle(Color.secondary)
+                }
+                .font(.system(size: 13, weight: .medium))
+                .foregroundStyle(Color.primary)
+                .padding(.vertical, 10)
             }
         }
         .padding(16)
@@ -364,42 +196,54 @@ struct ProfileView: View {
         .businessBorder(cornerRadius: 12)
     }
     
-    private func infoRow(label: String, value: String) -> some View {
-        HStack {
+    // MARK: - Actions
+
+    private func profileField(
+        label: String,
+        text: Binding<String>,
+        icon: String,
+        keyboard: UIKeyboardType = .default
+    ) -> some View {
+        VStack(alignment: .leading, spacing: 6) {
             Text(label)
                 .font(.system(size: 12))
                 .foregroundStyle(Color.secondary)
-            Spacer()
-            Text(value)
-                .font(.system(size: 12, weight: .regular, design: .monospaced))
-                .foregroundStyle(Color.primary)
+            HStack(spacing: 10) {
+                Image(systemName: icon)
+                    .foregroundStyle(Color.secondary)
+                    .frame(width: 18)
+                TextField(label, text: text)
+                    .textInputAutocapitalization(.never)
+                    .autocorrectionDisabled()
+                    .keyboardType(keyboard)
+            }
+            .font(.system(size: 14))
+            .padding(.horizontal, 12)
+            .padding(.vertical, 11)
+            .background(Color.cardBackground.opacity(0.3))
+            .businessBorder(cornerRadius: 8)
         }
     }
-    
-    // MARK: - Actions
-    
-    private func handleAddDevice() {
-        if authManager.isLoggedIn {
-            showScanDeviceModal = true
-        } else {
-            showAuthModal = true
-        }
+
+    private func loadProfileInputs() {
+        guard let user = authManager.currentUser else { return }
+        usernameInput = user.username
+        emailInput = user.email ?? ""
+        phoneInput = user.phoneNumber ?? ""
     }
-    
-    private func triggerAudioSync() {
-        guard let userID = authManager.currentUser?.id else {
-            showSyncAuthPrompt = true
-            return
-        }
-        Task {
-            let success = await syncManager.sync(sessions: sessions, modelContext: modelContext, userID: userID)
+
+    private func saveProfile() {
+        profileMessage = nil
+        authManager.updateProfile(
+            username: usernameInput,
+            email: emailInput,
+            phoneNumber: phoneInput,
+            avatarURL: nil
+        ) { success, error in
+            profileMessageIsError = !success
+            profileMessage = success ? "基本资料已保存" : (error ?? "保存失败")
             if success {
-                withAnimation {
-                    syncToastMessage = "服务器已确认全部记录与可用附件。"
-                }
-                DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
-                    syncToastMessage = nil
-                }
+                loadProfileInputs()
             }
         }
     }
@@ -421,5 +265,7 @@ struct ProfileView: View {
 }
 
 #Preview {
-    ProfileView()
+    NavigationStack {
+        ProfileView()
+    }
 }

+ 232 - 245
CelestiaTrace/Views/Settings/SettingsView.swift

@@ -1,323 +1,310 @@
 import SwiftUI
 
 // MARK: - SettingsView
-/// The settings screen with storage info, debug toggles,
-/// background keep-alive guide, and app branding.
-/// Redesigned to use a minimalist wireframe business style.
+
+/// A compact settings hub. Detailed controls live behind a single function list.
 struct SettingsView: View {
-    @State private var showClearCacheAlert = false
+    @ObservedObject private var authManager: AuthManager = .shared
     @State private var usedStorageBytes: Int64 = 0
     @State private var availableStorageBytes: Int64 = 0
+    @AppStorage(DeveloperLogStore.enabledKey) private var isDeveloperModeEnabled = false
 
     var body: some View {
         NavigationStack {
-            ZStack {
-                Color.spaceBlack.ignoresSafeArea()
-
-                ScrollView {
-                    VStack(spacing: 20) {
-                        // Section: Storage
-                        storageSection
-                            .padding(.top, 12)
+            List {
+                NavigationLink {
+                    ProfileView()
+                } label: {
+                    settingsRow(
+                        icon: "person.crop.circle",
+                        title: "账号",
+                        detail: accountSummary
+                    )
+                }
 
-                        // Section: Debug
-                        debugSection
+                NavigationLink {
+                    RecordingDevicesSettingsView()
+                } label: {
+                    settingsRow(
+                        icon: "mic",
+                        title: "我的录音设备"
+                    )
+                }
 
-                        // Section: Keep-Alive Guide
-                        keepAliveSection
+                NavigationLink {
+                    StorageSettingsView(
+                        usedStorageBytes: $usedStorageBytes,
+                        availableStorageBytes: $availableStorageBytes,
+                        reloadStorageMetrics: reloadStorageMetrics
+                    )
+                } label: {
+                    settingsRow(
+                        icon: "internaldrive",
+                        title: "存储空间",
+                        detail: formattedBytes(usedStorageBytes)
+                    )
+                }
 
-                        // Section: About
-                        aboutSection
+                NavigationLink {
+                    AboutSettingsView()
+                } label: {
+                    settingsRow(
+                        icon: "info.circle",
+                        title: "关于",
+                        detail: appVersion
+                    )
+                }
 
-                        // Footer
-                        footer
-                            .padding(.top, 12)
-                            .padding(.bottom, 40)
+                Section {
+                    Toggle(isOn: $isDeveloperModeEnabled) {
+                        settingsRow(
+                            icon: "hammer",
+                            title: "开发者模式"
+                        )
                     }
-                    .padding(.horizontal, 16)
+                    .tint(.celestiaCyan)
                 }
             }
+            .scrollContentBackground(.hidden)
+            .background(Color.spaceBlack)
+            .listStyle(.insetGrouped)
             .navigationTitle("设置")
             .navigationBarTitleDisplayMode(.inline)
-            .alert("清理缓存", isPresented: $showClearCacheAlert) {
-                Button("取消", role: .cancel) { }
-                Button("清理", role: .destructive) {
-                    clearTemporaryCache()
-                    HapticManager.trigger(.tapFeedback)
-                }
-            } message: {
-                Text("只清除系统缓存目录,不会删除录音、照片或会话记录。")
+            .task {
+                reloadStorageMetrics()
             }
-            .task { reloadStorageMetrics() }
         }
     }
 
-    // MARK: - Storage Section
-
-    private var storageSection: some View {
-        settingsCard {
-            VStack(alignment: .leading, spacing: 16) {
-                sectionHeader(icon: "internaldrive", title: "存储空间")
-
-                // Thin storage bar
-                VStack(alignment: .leading, spacing: 8) {
-                    GeometryReader { geometry in
-                        ZStack(alignment: .leading) {
-                            // Background bar
-                            RoundedRectangle(cornerRadius: 3)
-                                .fill(Color.primary.opacity(0.06))
-                                .frame(height: 5)
-
-                            // Used portion
-                            RoundedRectangle(cornerRadius: 3)
-                                .fill(Color.primary)
-                                .frame(
-                                    width: geometry.size.width * storageUsageRatio,
-                                    height: 5
-                                )
-                        }
-                    }
-                    .frame(height: 5)
-
-                    HStack {
-                        Text("App 数据 \(formattedBytes(usedStorageBytes))")
-                            .font(.system(size: 11, weight: .regular))
-                            .foregroundStyle(Color.primary.opacity(0.8))
+    private var accountSummary: String {
+        authManager.currentUser?.username ?? "未登录"
+    }
 
-                        Spacer()
+    private var appVersion: String {
+        Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0"
+    }
 
-                        Text("设备可用 \(formattedBytes(availableStorageBytes))")
-                            .font(.system(size: 11, weight: .regular))
-                            .foregroundStyle(Color.secondary)
-                    }
-                }
+    private func settingsRow(icon: String, title: String, detail: String? = nil) -> some View {
+        HStack(spacing: 14) {
+            Image(systemName: icon)
+                .font(.system(size: 17, weight: .regular))
+                .foregroundStyle(Color.celestiaCyan)
+                .frame(width: 24)
 
-                // Clear cache button (minimal red outline)
-                Button {
-                    showClearCacheAlert = true
-                } label: {
-                    HStack(spacing: 6) {
-                        Image(systemName: "trash")
-                            .font(.system(size: 12, weight: .light))
-                        Text("清理缓存")
-                            .font(.system(size: 13, weight: .regular))
-                    }
-                    .foregroundStyle(Color.recordingRed)
-                    .padding(.vertical, 8)
-                    .frame(maxWidth: .infinity)
-                    .overlay(
-                        RoundedRectangle(cornerRadius: 6)
-                            .stroke(Color.recordingRed.opacity(0.3), lineWidth: 1)
-                    )
-                }
-                .padding(.top, 4)
-            }
-        }
-    }
+            Text(title)
+                .foregroundStyle(Color.primary)
 
-    // MARK: - Debug Section
-
-    private var debugSection: some View {
-        settingsCard {
-            VStack(alignment: .leading, spacing: 14) {
-                sectionHeader(icon: "externaldrive.connected.to.line.below", title: "数据与同步")
-
-                // Data Storage Mode
-                VStack(alignment: .leading, spacing: 4) {
-                    HStack {
-                        Text("数据存储模式")
-                            .font(.system(size: 13, weight: .regular))
-                            .foregroundStyle(Color.primary)
-                        Spacer()
-                        Text("本地优先")
-                            .font(.system(size: 13, weight: .medium))
-                            .foregroundStyle(Color.secondary)
-                    }
+            Spacer()
 
-                    Text("记录先保存在本机;登录后由用户主动同步到服务器")
-                        .font(.system(size: 10))
-                        .foregroundStyle(Color.secondary.opacity(0.6))
-                }
+            if let detail {
+                Text(detail)
+                    .font(.system(size: 13))
+                    .foregroundStyle(Color.secondary)
+                    .lineLimit(1)
             }
         }
+        .padding(.vertical, 5)
     }
 
-    // MARK: - Keep-Alive Section
-
-    private var keepAliveSection: some View {
-        settingsCard {
-            VStack(alignment: .leading, spacing: 14) {
-                sectionHeader(icon: "shield", title: "后台运行保障")
-
-                Text("为保障全局不中断录音正常运行,请手动开启以下系统权限:")
-                    .font(.system(size: 12))
-                    .foregroundStyle(Color.secondary)
+    private func formattedBytes(_ bytes: Int64) -> String {
+        ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
+    }
 
-                VStack(alignment: .leading, spacing: 12) {
-                    keepAliveStep(
-                        number: 1,
-                        title: "开启后台音频",
-                        description: "Xcode 开启后台模式 -> 音频与 AirPlay",
-                        isChecked: true
-                    )
+    private func reloadStorageMetrics() {
+        let fileManager = FileManager.default
+        let roots = [
+            fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
+            fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+        ].compactMap { $0 }
 
-                    keepAliveStep(
-                        number: 2,
-                        title: "禁用自动锁屏",
-                        description: "系统设置 -> 显示与亮度 -> 自动锁定 -> 永不",
-                        isChecked: true
-                    )
+        usedStorageBytes = roots.reduce(0) { total, root in
+            let keys: Set<URLResourceKey> = [.isRegularFileKey, .fileSizeKey]
+            guard let enumerator = fileManager.enumerator(
+                at: root,
+                includingPropertiesForKeys: Array(keys)
+            ) else {
+                return total
+            }
 
-                    keepAliveStep(
-                        number: 3,
-                        title: "允许始终访问定位",
-                        description: "系统设置 -> 隐私与安全性 -> 定位服务 -> 现场记录 -> 始终允许",
-                        isChecked: false
-                    )
+            var subtotal: Int64 = 0
+            for case let url as URL in enumerator {
+                guard let values = try? url.resourceValues(forKeys: keys),
+                      values.isRegularFile == true else {
+                    continue
                 }
-                .padding(.top, 4)
+                subtotal += Int64(values.fileSize ?? 0)
             }
+            return total + subtotal
         }
-    }
 
-    private func keepAliveStep(number: Int, title: String, description: String, isChecked: Bool) -> some View {
-        HStack(alignment: .top, spacing: 10) {
-            ZStack {
-                Circle()
-                    .fill(Color.primary.opacity(isChecked ? 0.05 : 0.01))
-                    .frame(width: 24, height: 24)
-                    .businessBorder(cornerRadius: 12)
-
-                if isChecked {
-                    Image(systemName: "checkmark")
-                        .font(.system(size: 10, weight: .semibold))
-                        .foregroundStyle(Color.primary)
-                } else {
-                    Text("\(number)")
-                        .font(.system(size: 10, weight: .medium, design: .monospaced))
-                        .foregroundStyle(Color.secondary)
-                }
-            }
+        let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
+        availableStorageBytes = Int64(
+            (try? documents?.resourceValues(
+                forKeys: [.volumeAvailableCapacityForImportantUsageKey]
+            ).volumeAvailableCapacityForImportantUsage) ?? 0
+        )
+    }
+}
 
-            VStack(alignment: .leading, spacing: 2) {
-                Text(title)
-                    .font(.system(size: 13, weight: .medium))
-                    .foregroundStyle(Color.primary)
+// MARK: - Storage
 
-                Text(description)
-                    .font(.system(size: 11))
-                    .foregroundStyle(Color.secondary.opacity(0.8))
-            }
-        }
-    }
+private struct StorageSettingsView: View {
+    @Binding var usedStorageBytes: Int64
+    @Binding var availableStorageBytes: Int64
+    let reloadStorageMetrics: () -> Void
 
-    // MARK: - About Section
+    @State private var showClearCacheAlert = false
 
-    private var aboutSection: some View {
-        settingsCard {
-            VStack(spacing: 14) {
-                sectionHeader(icon: "info.circle", title: "关于")
+    var body: some View {
+        List {
+            Section {
+                metricRow(label: "App 数据", value: formattedBytes(usedStorageBytes))
+                metricRow(label: "设备可用", value: formattedBytes(availableStorageBytes))
+            }
 
-                VStack(spacing: 10) {
-                    infoRow(label: "应用名称", value: "星痕 CelestiaTrace")
-                    infoRow(label: "版本信息", value: "1.0.0")
-                    infoRow(label: "底层框架", value: "SwiftUI + SwiftData")
-                    infoRow(label: "系统支持", value: "iOS 17.0+")
+            Section {
+                Button("清理缓存", role: .destructive) {
+                    showClearCacheAlert = true
                 }
-                .padding(.top, 4)
+            } footer: {
+                Text("只清除系统缓存目录,不会删除录音、照片或会话记录。")
             }
         }
+        .scrollContentBackground(.hidden)
+        .background(Color.spaceBlack)
+        .navigationTitle("存储空间")
+        .navigationBarTitleDisplayMode(.inline)
+        .alert("清理缓存", isPresented: $showClearCacheAlert) {
+            Button("取消", role: .cancel) {}
+            Button("清理", role: .destructive) {
+                clearTemporaryCache()
+                HapticManager.trigger(.tapFeedback)
+            }
+        } message: {
+            Text("只清除系统缓存目录,不会删除录音、照片或会话记录。")
+        }
     }
 
-    private func infoRow(label: String, value: String) -> some View {
+    private func metricRow(label: String, value: String) -> some View {
         HStack {
             Text(label)
-                .font(.system(size: 12))
-                .foregroundStyle(Color.secondary)
-
             Spacer()
-
             Text(value)
-                .font(.system(size: 12, weight: .regular, design: .monospaced))
-                .foregroundStyle(Color.primary)
+                .foregroundStyle(Color.secondary)
         }
     }
 
-    // MARK: - Footer
+    private func formattedBytes(_ bytes: Int64) -> String {
+        ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
+    }
 
-    private var footer: some View {
-        VStack(spacing: 4) {
-            Text("星痕现场记录")
-                .font(.system(size: 10, weight: .bold, design: .monospaced))
-                .foregroundStyle(Color.secondary.opacity(0.4))
-                .tracking(2)
+    private func clearTemporaryCache() {
+        let fileManager = FileManager.default
+        guard let cacheURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first,
+              let children = try? fileManager.contentsOfDirectory(
+                at: cacheURL,
+                includingPropertiesForKeys: nil
+              ) else {
+            return
+        }
 
-            Text("极简专业现场记录工作台")
-                .font(.system(size: 9))
-                .foregroundStyle(Color.secondary.opacity(0.3))
+        for child in children {
+            try? fileManager.removeItem(at: child)
         }
+        reloadStorageMetrics()
     }
+}
+
+// MARK: - Recording Devices
 
-    // MARK: - Reusable Components
+private struct RecordingDevicesSettingsView: View {
+    @ObservedObject private var authManager: AuthManager = .shared
+    @ObservedObject private var bleManager: BLEManager = .shared
+    @State private var showAuthModal = false
+    @State private var showScanDeviceModal = false
 
-    private func settingsCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
-        VStack(alignment: .leading, spacing: 0) {
-            content()
+    var body: some View {
+        ScrollView {
+            DeviceListView(
+                bleManager: bleManager,
+                authManager: authManager,
+                onAddDeviceClicked: handleAddDevice
+            )
+            .padding(16)
+        }
+        .background(Color.spaceBlack)
+        .navigationTitle("我的录音设备")
+        .navigationBarTitleDisplayMode(.inline)
+        .sheet(isPresented: $showAuthModal) {
+            AuthModalView(authManager: authManager)
+        }
+        .sheet(isPresented: $showScanDeviceModal) {
+            DeviceScanView(bleManager: bleManager, authManager: authManager)
         }
-        .padding(16)
-        .background(Color.cardBackground.opacity(0.15))
-        .businessBorder(cornerRadius: 10)
     }
 
-    private func sectionHeader(icon: String, title: String) -> some View {
-        HStack(spacing: 8) {
-            Image(systemName: icon)
-                .font(.system(size: 12, weight: .light))
-                .foregroundStyle(Color.secondary)
-            Text(title)
-                .font(.system(size: 14, weight: .semibold))
-                .foregroundStyle(Color.primary)
+    private func handleAddDevice() {
+        if authManager.isLoggedIn {
+            showScanDeviceModal = true
+        } else {
+            showAuthModal = true
         }
     }
+}
+
+// MARK: - About
+
+private struct AboutSettingsView: View {
+    var body: some View {
+        List {
+            Section {
+                infoRow(label: "应用名称", value: "星痕 CelestiaTrace")
+                infoRow(label: "版本信息", value: appVersion)
+                infoRow(label: "系统支持", value: "iOS 17.0+")
+            }
 
-    private var storageUsageRatio: Double {
-        let total = usedStorageBytes + availableStorageBytes
-        guard total > 0 else { return 0 }
-        return min(1, Double(usedStorageBytes) / Double(total))
+            Section {
+                guidanceRow(
+                    title: "自动锁定",
+                    detail: "系统设置 → 显示与亮度 → 自动锁定"
+                )
+                guidanceRow(
+                    title: "定位权限",
+                    detail: "系统设置 → 隐私与安全性 → 定位服务"
+                )
+            } header: {
+                Text("后台运行保障")
+            } footer: {
+                Text("长时间现场记录前,请确认系统权限与电量充足。")
+            }
+        }
+        .scrollContentBackground(.hidden)
+        .background(Color.spaceBlack)
+        .navigationTitle("关于")
+        .navigationBarTitleDisplayMode(.inline)
     }
 
-    private func formattedBytes(_ bytes: Int64) -> String {
-        ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
+    private var appVersion: String {
+        Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0"
     }
 
-    private func reloadStorageMetrics() {
-        let fileManager = FileManager.default
-        let roots = [
-            fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
-            fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
-        ].compactMap { $0 }
-        usedStorageBytes = roots.reduce(0) { total, root in
-            let keys: Set<URLResourceKey> = [.isRegularFileKey, .fileSizeKey]
-            guard let enumerator = fileManager.enumerator(at: root, includingPropertiesForKeys: Array(keys)) else { return total }
-            var subtotal: Int64 = 0
-            for case let url as URL in enumerator {
-                guard let values = try? url.resourceValues(forKeys: keys), values.isRegularFile == true else { continue }
-                subtotal += Int64(values.fileSize ?? 0)
-            }
-            return total + subtotal
+    private func infoRow(label: String, value: String) -> some View {
+        HStack {
+            Text(label)
+            Spacer()
+            Text(value)
+                .foregroundStyle(Color.secondary)
         }
-        let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
-        availableStorageBytes = Int64((try? documents?.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]).volumeAvailableCapacityForImportantUsage) ?? 0)
     }
 
-    private func clearTemporaryCache() {
-        let fileManager = FileManager.default
-        guard let cacheURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first,
-              let children = try? fileManager.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil) else { return }
-        for child in children {
-            try? fileManager.removeItem(at: child)
+    private func guidanceRow(title: String, detail: String) -> some View {
+        VStack(alignment: .leading, spacing: 5) {
+            Text(title)
+            Text(detail)
+                .font(.system(size: 12))
+                .foregroundStyle(Color.secondary)
         }
-        reloadStorageMetrics()
+        .padding(.vertical, 3)
     }
 }
 

+ 71 - 26
CelestiaTraceLiveActivity/RecordingLiveActivityWidget.swift

@@ -24,23 +24,35 @@ struct RecordingLiveActivityWidget: Widget {
                 }
 
                 DynamicIslandExpandedRegion(.bottom) {
-                    HStack {
+                    VStack(spacing: 8) {
                         Text(context.attributes.title)
                             .font(.caption)
                             .lineLimit(1)
+                            .frame(maxWidth: .infinity, alignment: .leading)
 
-                        Spacer()
-
-                        quickAction(
-                            title: "拍照",
-                            systemImage: "camera.fill",
-                            url: actionURL("photo", context.attributes)
-                        )
-                        quickAction(
-                            title: "文字",
-                            systemImage: "note.text",
-                            url: actionURL("note", context.attributes)
-                        )
+                        HStack(spacing: 8) {
+                            islandAction(
+                                label: context.state.isPaused ? "继续" : "暂停",
+                                systemImage: context.state.isPaused ? "play.fill" : "pause.fill",
+                                url: actionURL("toggle-pause", context.attributes)
+                            )
+                            islandAction(
+                                label: "结束",
+                                systemImage: "stop.fill",
+                                url: actionURL("stop", context.attributes),
+                                tint: .red
+                            )
+                            islandAction(
+                                label: "拍照",
+                                systemImage: "camera.fill",
+                                url: actionURL("photo", context.attributes)
+                            )
+                            islandAction(
+                                label: "文字",
+                                systemImage: "note.text",
+                                url: actionURL("note", context.attributes)
+                            )
+                        }
                     }
                     .foregroundStyle(.white)
                 }
@@ -87,17 +99,32 @@ struct RecordingLiveActivityWidget: Widget {
                     .foregroundStyle(.white)
             }
 
-            HStack(spacing: 10) {
-                quickAction(
-                    title: "拍照记录",
-                    systemImage: "camera.fill",
-                    url: actionURL("photo", context.attributes)
-                )
-                quickAction(
-                    title: "文字记录",
-                    systemImage: "note.text",
-                    url: actionURL("note", context.attributes)
-                )
+            VStack(spacing: 8) {
+                HStack(spacing: 10) {
+                    quickAction(
+                        title: context.state.isPaused ? "继续记录" : "暂停记录",
+                        systemImage: context.state.isPaused ? "play.fill" : "pause.fill",
+                        url: actionURL("toggle-pause", context.attributes)
+                    )
+                    quickAction(
+                        title: "结束记录",
+                        systemImage: "stop.fill",
+                        url: actionURL("stop", context.attributes),
+                        tint: .red
+                    )
+                }
+                HStack(spacing: 10) {
+                    quickAction(
+                        title: "拍照记录",
+                        systemImage: "camera.fill",
+                        url: actionURL("photo", context.attributes)
+                    )
+                    quickAction(
+                        title: "文字记录",
+                        systemImage: "note.text",
+                        url: actionURL("note", context.attributes)
+                    )
+                }
             }
         }
         .padding(16)
@@ -122,18 +149,36 @@ struct RecordingLiveActivityWidget: Widget {
     private func quickAction(
         title: String,
         systemImage: String,
-        url: URL
+        url: URL,
+        tint: Color = .white
     ) -> some View {
         Link(destination: url) {
             Label(title, systemImage: systemImage)
                 .font(.caption.weight(.semibold))
-                .foregroundStyle(.white)
+                .foregroundStyle(tint)
                 .frame(maxWidth: .infinity)
                 .padding(.vertical, 8)
                 .background(.white.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
         }
     }
 
+    private func islandAction(
+        label: String,
+        systemImage: String,
+        url: URL,
+        tint: Color = .white
+    ) -> some View {
+        Link(destination: url) {
+            Image(systemName: systemImage)
+                .font(.caption.weight(.semibold))
+                .foregroundStyle(tint)
+                .frame(maxWidth: .infinity)
+                .padding(.vertical, 7)
+                .background(.white.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
+                .accessibilityLabel(label)
+        }
+    }
+
     private func recordingURL(_ attributes: RecordingActivityAttributes) -> URL {
         URL(string: "celestiatrace://recording/\(attributes.sessionID.uuidString)")!
     }

+ 14 - 0
Docs/RemoteAPI.openapi.yaml

@@ -766,6 +766,20 @@ components:
         voiceEndOffsetMs:
           type: [integer, "null"]
           format: int64
+        locationName:
+          type: [string, "null"]
+        locationAddress:
+          type: [string, "null"]
+        latitude:
+          type: [number, "null"]
+          format: double
+          minimum: -90
+          maximum: 90
+        longitude:
+          type: [number, "null"]
+          format: double
+          minimum: -180
+          maximum: 180
     SessionUpsertRequest:
       type: object
       required: [clientId, title, startTime, durationMs, events, deletedEventClientIds]