7 Angajamente 34df40c131 ... c6ec2e98db

Autor SHA1 Permisiunea de a trimite mesaje. Dacă este dezactivată, utilizatorul nu va putea trimite nici un fel de mesaj Data
  bob.yuxinyang c6ec2e98db refactor(project): 模块目录重构为 FieldRecords 并更新 Xcode 工程与权限配置 1 lună în urmă
  bob.yuxinyang d9ca9900a3 feat(developer): 增加开发者诊断日志浮层 (DeveloperLogOverlay) 与蓝牙/网络日志搜集 1 lună în urmă
  bob.yuxinyang 8821a413ce feat(location/detail): 增加事件地理位置打点、全屏双指缩放图片预览与事件内容编辑 1 lună în urmă
  bob.yuxinyang 507a364859 feat(ui/title): 优化记录标题格式与编辑框后缀高亮选中体验 1 lună în urmă
  bob.yuxinyang d50d24aa61 feat(remote/history): 新增远程会话 DELETE 接口支持与同步删除 1 lună în urmă
  bob.yuxinyang f0ad681fbf feat(ui): 增加会话详情云端信息面板、列表同步中动画与自动同步对接 1 lună în urmă
  bob.yuxinyang 3d00c96c1c refactor(model/sync): 重构 CelestiaSession 同步状态判定与 SyncManager 自动后台队列引擎 1 lună în urmă
25 a modificat fișierele cu 3313 adăugiri și 882 ștergeri
  1. 11 3
      CelestiaTrace.xcodeproj/project.pbxproj
  2. 18 1
      CelestiaTrace/Models/CelestiaSession.swift
  3. 42 0
      CelestiaTrace/Models/CelestiaTimelineEvent.swift
  4. 2 0
      CelestiaTrace/Resources/Info.plist
  5. 44 0
      CelestiaTrace/Services/Bluetooth/BLEManager.swift
  6. 1 0
      CelestiaTrace/Services/Network/NetworkServiceProtocol.swift
  7. 323 53
      CelestiaTrace/Services/Network/RemoteNetworkService.swift
  8. 5 0
      CelestiaTrace/Services/RecordingLiveActivityManager.swift
  9. 13 7
      CelestiaTrace/ViewModels/RecordingViewModel.swift
  10. 215 0
      CelestiaTrace/Views/Components/DeveloperLogOverlay.swift
  11. 238 0
      CelestiaTrace/Views/Components/ImagePicker.swift
  12. 33 45
      CelestiaTrace/Views/Components/PulsingRecordButton.swift
  13. 580 0
      CelestiaTrace/Views/Components/TimelineLocationPicker.swift
  14. 71 21
      CelestiaTrace/Views/ContentView.swift
  15. 65 15
      CelestiaTrace/Views/Detail/ChronoFeedRow.swift
  16. 46 10
      CelestiaTrace/Views/Detail/MultiTrackTimeline.swift
  17. 642 103
      CelestiaTrace/Views/Detail/SessionDetailView.swift
  18. 97 23
      CelestiaTrace/Views/FieldRecords/SessionListView.swift
  19. 208 8
      CelestiaTrace/Views/Home/HomeView.swift
  20. 119 273
      CelestiaTrace/Views/Profile/ProfileView.swift
  21. 203 49
      CelestiaTrace/Views/Recording/ActiveRecordingView.swift
  22. 232 245
      CelestiaTrace/Views/Settings/SettingsView.swift
  23. 71 26
      CelestiaTraceLiveActivity/RecordingLiveActivityWidget.swift
  24. 33 0
      Docs/RemoteAPI.openapi.yaml
  25. 1 0
      generate_project.rb

+ 11 - 3
CelestiaTrace.xcodeproj/project.pbxproj

@@ -54,6 +54,8 @@
 		C7FCC7859292F4917426A553 /* CelestiaTraceLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CA6784C282FC33FAD436B8 /* CelestiaTraceLiveActivityBundle.swift */; };
 		C8794B9720CD85F518A82473 /* BoundDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF9EF0F961DB2B546B9C0F1 /* BoundDevice.swift */; };
 		CD45B55AB993D054E50A43BE /* RealAudioRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 977EB01CFA491A1573D7CF22 /* RealAudioRecorder.swift */; };
+		C0DE10000000000000000001 /* TimelineLocationPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE10000000000000000002 /* TimelineLocationPicker.swift */; };
+		C0DE20000000000000000001 /* DeveloperLogOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE20000000000000000002 /* DeveloperLogOverlay.swift */; };
 		DDC0D9C4883B4861CBC74C39 /* ChronoFeedRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9275B988BEBBEA80370E1AC4 /* ChronoFeedRow.swift */; };
 		E78FC729614A4C93C97D564D /* ColorTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95AF20E8EF4319BBEBA608C1 /* ColorTheme.swift */; };
 		F385E2FCB06D39917F51846E /* AudioRecorderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B407473A307EEAF2CB5EE94 /* AudioRecorderProtocol.swift */; };
@@ -140,6 +142,8 @@
 		AA13A08133A50094CBFFDDF9 /* EditProfileModalView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EditProfileModalView.swift; sourceTree = "<group>"; };
 		AEED3C75367F66D6D6CE745D /* SilenceDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SilenceDetector.swift; sourceTree = "<group>"; };
 		B7B9693BD9924C08A1F2ECD1 /* ActiveRecordingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ActiveRecordingView.swift; sourceTree = "<group>"; };
+		C0DE10000000000000000002 /* TimelineLocationPicker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TimelineLocationPicker.swift; sourceTree = "<group>"; };
+		C0DE20000000000000000002 /* DeveloperLogOverlay.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeveloperLogOverlay.swift; sourceTree = "<group>"; };
 		DADF67536C0CF9AE7F405C74 /* ImagePicker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = "<group>"; };
 		DB09EA3EE9CFFADB34B5B43A /* RecordingEnvironmentDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecordingEnvironmentDetector.swift; sourceTree = "<group>"; };
 		DB56F0DFCB1FE77D7636B43D /* CelestiaTrace.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CelestiaTrace.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -297,18 +301,18 @@
 				ADD73EAAF268C8B37E097585 /* Components */,
 				21AD838392427D841273B592 /* Detail */,
 				44E01A3C9C72DBC8106DEE17 /* Profile */,
-				96406E65C0CB98B68F7082F4 /* History */,
+				96406E65C0CB98B68F7082F4 /* FieldRecords */,
 				71787388F9456A4784534BCC /* ContentView.swift */,
 			);
 			path = Views;
 			sourceTree = "<group>";
 		};
-		96406E65C0CB98B68F7082F4 /* History */ = {
+		96406E65C0CB98B68F7082F4 /* FieldRecords */ = {
 			isa = PBXGroup;
 			children = (
 				0EBB5FC2AC0B9D31C7A04715 /* SessionListView.swift */,
 			);
-			path = History;
+			path = FieldRecords;
 			sourceTree = "<group>";
 		};
 		A9B8C7D6E5F4000000000005 /* Splash */ = {
@@ -342,6 +346,8 @@
 			children = (
 				4F4B4B59E0916CE9ACFF362F /* DiscreetWarningBanner.swift */,
 				DADF67536C0CF9AE7F405C74 /* ImagePicker.swift */,
+				C0DE20000000000000000002 /* DeveloperLogOverlay.swift */,
+				C0DE10000000000000000002 /* TimelineLocationPicker.swift */,
 				3C76F629F3460D6F077C3708 /* SyncAuthPromptModal.swift */,
 				77D23B83EDDD5489004E42DC /* PulsingRecordButton.swift */,
 			);
@@ -607,6 +613,8 @@
 				8B5C51BB4474283BDF4ADACB /* LiveWaveformView.swift in Sources */,
 				75AE08DD135D81A3E19190EE /* DiscreetWarningBanner.swift in Sources */,
 				F5035B942DE1FEC10515D9D6 /* ImagePicker.swift in Sources */,
+				C0DE20000000000000000001 /* DeveloperLogOverlay.swift in Sources */,
+				C0DE10000000000000000001 /* TimelineLocationPicker.swift in Sources */,
 				2FD7EE603C849E281034CDD8 /* SyncAuthPromptModal.swift in Sources */,
 				AF09E026C9AB15874D870E1F /* PulsingRecordButton.swift in Sources */,
 				2C16272F61F8B36A2EF69129 /* SessionDetailView.swift in Sources */,

+ 18 - 1
CelestiaTrace/Models/CelestiaSession.swift

@@ -58,10 +58,27 @@ final class CelestiaSession {
         set { syncStateRaw = newValue.rawValue }
     }
 
+    /// A server-confirmed sync requires both the legacy completion flag and a
+    /// remote identifier. This remains compatible with records created before
+    /// `syncStateRaw` was added, whose migrated state defaults to `localOnly`.
+    var hasConfirmedCloudSync: Bool {
+        isSynced && cloudSessionId != nil
+    }
+
+    var needsCloudSync: Bool {
+        !hasConfirmedCloudSync
+    }
+
+    func markContentModified() {
+        isSynced = false
+        syncState = .pending
+        lastSyncError = nil
+    }
+
     /// The revision represented by the current local content.
     /// Unsynced edits form the next revision based on the last known server revision.
     var localRevision: Int64 {
-        if isSynced && syncState == .synced {
+        if hasConfirmedCloudSync {
             return max(serverRevision, 1)
         }
         return max(serverRevision + 1, 1)

+ 42 - 0
CelestiaTrace/Models/CelestiaTimelineEvent.swift

@@ -1,6 +1,18 @@
 import Foundation
 import SwiftData
 
+struct TimelineLocation: Hashable {
+    let name: String
+    let address: String
+    let latitude: Double
+    let longitude: Double
+
+    var subtitle: String {
+        let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
+        return trimmedAddress == name ? "" : trimmedAddress
+    }
+}
+
 @Model
 final class CelestiaTimelineEvent {
     var id: UUID
@@ -10,6 +22,11 @@ final class CelestiaTimelineEvent {
     var localFilePath: String?
     var voiceStartOffsetMs: Int64?
     var voiceEndOffsetMs: Int64?
+    // Optional defaults keep existing SwiftData stores compatible.
+    var locationName: String? = nil
+    var locationAddress: String? = nil
+    var latitude: Double? = nil
+    var longitude: Double? = nil
     var createdAt: Date
     
     var session: CelestiaSession?
@@ -28,6 +45,31 @@ final class CelestiaTimelineEvent {
     var isContinuationMarker: Bool {
         eventType == "MARKER" && textContent?.hasPrefix("续录时间:") == true
     }
+
+    var location: TimelineLocation? {
+        get {
+            guard let latitude, let longitude else { return nil }
+            let fallbackName = locationAddress?
+                .trimmingCharacters(in: .whitespacesAndNewlines)
+            let name = locationName?
+                .trimmingCharacters(in: .whitespacesAndNewlines)
+            let displayName = (name?.isEmpty == false ? name : fallbackName)
+                .flatMap { $0.isEmpty ? nil : $0 }
+                ?? "所在位置"
+            return TimelineLocation(
+                name: displayName,
+                address: locationAddress ?? "",
+                latitude: latitude,
+                longitude: longitude
+            )
+        }
+        set {
+            locationName = newValue?.name
+            locationAddress = newValue?.address
+            latitude = newValue?.latitude
+            longitude = newValue?.longitude
+        }
+    }
     
     var eventIcon: String {
         if isContinuationMarker {

+ 2 - 0
CelestiaTrace/Resources/Info.plist

@@ -34,6 +34,8 @@
 	<string>需要相机权限以在现场录制中拍摄照片记录</string>
 	<key>NSMicrophoneUsageDescription</key>
 	<string>需要麦克风权限以使用 iPhone 进行现场录音</string>
+	<key>NSLocationWhenInUseUsageDescription</key>
+	<string>需要定位权限以在笔记和照片记录中插入所在位置</string>
 	<key>NSPhotoLibraryUsageDescription</key>
 	<string>需要相册权限以选择照片记录</string>
 	<key>UIBackgroundModes</key>

+ 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.

+ 1 - 0
CelestiaTrace/Services/Network/NetworkServiceProtocol.swift

@@ -6,6 +6,7 @@ protocol NetworkServiceProtocol {
         _ session: CelestiaSession,
         deletedEventClientIDs: [String]
     ) async throws -> RemoteSession
+    func deleteSession(sessionID: String) async throws
     func fetchStorageQuota() async throws -> StorageQuota
     func uploadAsset(
         sessionID: String,

+ 323 - 53
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,
@@ -162,6 +174,14 @@ final class RemoteNetworkService: NetworkServiceProtocol {
         return try await client.request("sessions", method: .post, body: try encoder.encode(payload), authenticated: true)
     }
 
+    func deleteSession(sessionID: String) async throws {
+        try await client.requestVoid(
+            "sessions/\(sessionID)",
+            method: .delete,
+            authenticated: true
+        )
+    }
+
     func fetchStorageQuota() async throws -> StorageQuota {
         try await client.request("storage/quota", authenticated: true)
     }
@@ -230,32 +250,35 @@ final class RemoteNetworkService: NetworkServiceProtocol {
             authenticated: true
         )
 
-        let input = try FileHandle(forReadingFrom: fileURL)
-        defer { try? input.close() }
         for chunkIndex in 0..<upload.totalChunks {
             let offset = UInt64(chunkIndex) * UInt64(upload.chunkSize)
-            try input.seek(toOffset: offset)
-            guard let data = try input.read(upToCount: upload.chunkSize), !data.isEmpty else {
-                throw APIError.transport("读取上传分片失败")
-            }
-            let temporaryURL = FileManager.default.temporaryDirectory
-                .appendingPathComponent("celestia-\(upload.uploadId)-\(chunkIndex).chunk")
-            try data.write(to: temporaryURL, options: .atomic)
-            defer { try? FileManager.default.removeItem(at: temporaryURL) }
+            let preparedChunk = try await prepareUploadChunk(
+                fileURL: fileURL,
+                uploadID: upload.uploadId,
+                chunkIndex: chunkIndex,
+                chunkSize: upload.chunkSize,
+                offset: offset
+            )
 
             let chunkStart = Double(offset) / Double(fileSize)
-            let chunkSpan = Double(data.count) / Double(fileSize)
-            let _: ChunkUploadProgressResponse = try await client.upload(
-                "sessions/\(sessionID)/assets/chunk",
-                fileURL: temporaryURL,
-                fileName: "chunk-\(chunkIndex)",
-                mimeType: "application/octet-stream",
-                fields: [
-                    "uploadId": upload.uploadId,
-                    "chunkIndex": String(chunkIndex)
-                ]
-            ) { fraction in
-                progress(min(max(chunkStart + chunkSpan * fraction, 0), 1))
+            let chunkSpan = Double(preparedChunk.byteCount) / Double(fileSize)
+            do {
+                let _: ChunkUploadProgressResponse = try await client.upload(
+                    "sessions/\(sessionID)/assets/chunk",
+                    fileURL: preparedChunk.url,
+                    fileName: "chunk-\(chunkIndex)",
+                    mimeType: "application/octet-stream",
+                    fields: [
+                        "uploadId": upload.uploadId,
+                        "chunkIndex": String(chunkIndex)
+                    ]
+                ) { fraction in
+                    progress(min(max(chunkStart + chunkSpan * fraction, 0), 1))
+                }
+                try? FileManager.default.removeItem(at: preparedChunk.url)
+            } catch {
+                try? FileManager.default.removeItem(at: preparedChunk.url)
+                throw error
             }
         }
 
@@ -273,6 +296,34 @@ final class RemoteNetworkService: NetworkServiceProtocol {
         return AssetUploadResult(asset: payload.asset, sessionRevision: payload.sessionRevision)
     }
 
+    private func prepareUploadChunk(
+        fileURL: URL,
+        uploadID: String,
+        chunkIndex: Int,
+        chunkSize: Int,
+        offset: UInt64
+    ) async throws -> (url: URL, byteCount: Int) {
+        let task = Task.detached(priority: .utility) {
+            try Task.checkCancellation()
+            let input = try FileHandle(forReadingFrom: fileURL)
+            defer { try? input.close() }
+            try input.seek(toOffset: offset)
+            guard let data = try input.read(upToCount: chunkSize), !data.isEmpty else {
+                throw APIError.transport("读取上传分片失败")
+            }
+            try Task.checkCancellation()
+            let temporaryURL = FileManager.default.temporaryDirectory
+                .appendingPathComponent("celestia-\(uploadID)-\(chunkIndex).chunk")
+            try data.write(to: temporaryURL, options: .atomic)
+            return (temporaryURL, data.count)
+        }
+        return try await withTaskCancellationHandler {
+            try await task.value
+        } onCancel: {
+            task.cancel()
+        }
+    }
+
     private func fileSize(of url: URL) throws -> Int64 {
         let values = try url.resourceValues(forKeys: [.fileSizeKey])
         guard let size = values.fileSize else {
@@ -391,6 +442,8 @@ final class SyncManager: ObservableObject {
 
     private let service: NetworkServiceProtocol
     private var activeSyncTask: Task<Bool, Never>?
+    private var automaticSyncTasks: [UUID: Task<Void, Never>] = [:]
+    private var automaticSyncGenerations: [UUID: UUID] = [:]
 
     init(service: NetworkServiceProtocol = RemoteNetworkService()) {
         self.service = service
@@ -412,12 +465,63 @@ final class SyncManager: ObservableObject {
         }
     }
 
+    func scheduleAutomaticSync(
+        for session: CelestiaSession,
+        modelContext: ModelContext,
+        delay: Duration = .seconds(1.5)
+    ) {
+        guard session.endTime != nil,
+              session.syncState != .conflict,
+              AuthManager.shared.currentUser?.id != nil else { return }
+
+        let sessionID = session.id
+        let generation = UUID()
+        automaticSyncGenerations[sessionID] = generation
+        automaticSyncTasks[sessionID]?.cancel()
+        if isSyncing, activeSessionID == sessionID {
+            activeSyncTask?.cancel()
+        }
+        automaticSyncTasks[sessionID] = Task { @MainActor [weak self] in
+            do {
+                try await Task.sleep(for: delay)
+                while let self,
+                      (self.isSyncing || !NetworkStatusMonitor.shared.isConnected) {
+                    try Task.checkCancellation()
+                    try await Task.sleep(for: .seconds(0.75))
+                }
+                guard let self,
+                      !Task.isCancelled,
+                      self.automaticSyncGenerations[sessionID] == generation,
+                      session.needsCloudSync,
+                      let userID = AuthManager.shared.currentUser?.id else { return }
+                _ = await self.sync(
+                    sessions: [session],
+                    modelContext: modelContext,
+                    userID: userID
+                )
+                if self.automaticSyncGenerations[sessionID] == generation {
+                    self.automaticSyncTasks[sessionID] = nil
+                    self.automaticSyncGenerations[sessionID] = nil
+                }
+            } catch {
+                guard let self,
+                      self.automaticSyncGenerations[sessionID] == generation else { return }
+                self.automaticSyncTasks[sessionID] = nil
+                self.automaticSyncGenerations[sessionID] = nil
+            }
+        }
+    }
+
     func sync(
         sessions: [CelestiaSession],
         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(
@@ -435,6 +539,11 @@ final class SyncManager: ObservableObject {
         activeSyncTask = nil
         isSyncing = false
         activeSessionID = nil
+        DeveloperLogStore.log(
+            "现场记录同步",
+            result ? "同步任务完成" : "同步任务未完成",
+            level: result ? .success : .warning
+        )
         return result
     }
 
@@ -452,27 +561,48 @@ final class SyncManager: ObservableObject {
         completedCount = 0
         activeSessionID = nil
         syncProgress = 0
-        totalCount = sessions.filter {
-            ($0.ownerUserID == nil || $0.ownerUserID == userID) && (!$0.isSynced || $0.cloudSessionId == nil)
-        }.count
+        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.isSynced || $0.cloudSessionId == nil)
+                    && $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)
@@ -480,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
@@ -509,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
@@ -520,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()
             }
@@ -536,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
         }
     }
@@ -564,7 +716,7 @@ final class SyncManager: ObservableObject {
             guard let url = AudioPathHelper.resolveURL(for: path) else {
                 throw APIError.transport("本地录音文件不存在:\(path)")
             }
-            let sha256 = try fileSHA256(of: url)
+            let sha256 = try await fileSHA256(of: url)
             let hasMatchingAudio = (remote.assets ?? []).contains {
                 $0.kind.uppercased() == "AUDIO"
                     && $0.sha256.caseInsensitiveCompare(sha256) == .orderedSame
@@ -622,15 +774,23 @@ final class SyncManager: ObservableObject {
         return Int64(values?.fileSize ?? 0)
     }
 
-    private func fileSHA256(of url: URL) throws -> String {
-        let input = try FileHandle(forReadingFrom: url)
-        defer { try? input.close() }
-        var hasher = SHA256()
-        while let data = try input.read(upToCount: 1_024 * 1_024),
-              !data.isEmpty {
-            hasher.update(data: data)
+    private func fileSHA256(of url: URL) async throws -> String {
+        let task = Task.detached(priority: .utility) {
+            let input = try FileHandle(forReadingFrom: url)
+            defer { try? input.close() }
+            var hasher = SHA256()
+            while let data = try input.read(upToCount: 1_024 * 1_024),
+                  !data.isEmpty {
+                try Task.checkCancellation()
+                hasher.update(data: data)
+            }
+            return hasher.finalize().map { String(format: "%02x", $0) }.joined()
+        }
+        return try await withTaskCancellationHandler {
+            try await task.value
+        } onCancel: {
+            task.cancel()
         }
-        return hasher.finalize().map { String(format: "%02x", $0) }.joined()
     }
 
     private func downloadMissingAssets(for session: CelestiaSession, remote: RemoteSession) async throws {
@@ -641,7 +801,12 @@ final class SyncManager: ObservableObject {
                 ($0.createdAt ?? .distantPast) < ($1.createdAt ?? .distantPast)
             }) {
             let localURL = AudioPathHelper.resolveURL(for: session.localAudioPath)
-            let localHash = localURL.flatMap { try? fileSHA256(of: $0) }
+            let localHash: String?
+            if let localURL {
+                localHash = try? await fileSHA256(of: localURL)
+            } else {
+                localHash = nil
+            }
             let needsDownload = localURL == nil
                 || (session.isSynced
                     && localHash?.caseInsensitiveCompare(audio.sha256) != .orderedSame)
@@ -652,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()
@@ -677,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,
@@ -694,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))
@@ -702,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
@@ -723,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)
         }
     }

+ 5 - 0
CelestiaTrace/Services/RecordingLiveActivityManager.swift

@@ -9,6 +9,11 @@ final class RecordingLiveActivityManager {
 
     private init() {}
 
+    var recoverableSessionID: UUID? {
+        activity?.attributes.sessionID
+            ?? Activity<RecordingActivityAttributes>.activities.first?.attributes.sessionID
+    }
+
     func start(
         sessionID: UUID,
         title: String,

+ 13 - 7
CelestiaTrace/ViewModels/RecordingViewModel.swift

@@ -20,6 +20,9 @@ final class RecordingViewModel {
     private(set) var sourceDisplayName: String
     private(set) var statusMessage: String = "正在准备"
     private(set) var errorMessage: String?
+    /// URL of the segment currently being recorded. Exposed as observed state
+    /// so the recovery coordinator can persist it as soon as it is allocated.
+    private(set) var outputFileURL: URL?
 
     // MARK: - Computed Properties
 
@@ -32,11 +35,6 @@ final class RecordingViewModel {
         return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
     }
 
-    /// Local URL where the audio file is stored.
-    var outputFileURL: URL? {
-        recorder.outputFileURL
-    }
-
     // MARK: - Private
 
     /// Concrete recorder instance conforming to AudioRecorderProtocol.
@@ -121,10 +119,17 @@ final class RecordingViewModel {
     /// - Parameter session: The active `CelestiaSession` to attach the event to.
     /// - Returns: The newly created `CelestiaTimelineEvent`.
     @discardableResult
-    func addPhotoEvent(to session: CelestiaSession, localFilePath: String? = nil) -> CelestiaTimelineEvent {
-        let relativeMs = Int64(elapsedTime * 1000)
+    func addPhotoEvent(
+        to session: CelestiaSession,
+        relativeTimeMs: Int64? = nil,
+        localFilePath: String? = nil,
+        note: String? = nil
+    ) -> CelestiaTimelineEvent {
+        let relativeMs = relativeTimeMs ?? Int64(elapsedTime * 1000)
         let event = CelestiaTimelineEvent(relativeTimeMs: relativeMs, eventType: "PHOTO")
         event.localFilePath = localFilePath
+        let trimmedNote = note?.trimmingCharacters(in: .whitespacesAndNewlines)
+        event.textContent = trimmedNote?.isEmpty == false ? trimmedNote : nil
         session.events.append(event)
         session.isSynced = false
         session.syncState = .pending
@@ -192,5 +197,6 @@ final class RecordingViewModel {
         sourceDisplayName = recorder.sourceDisplayName
         statusMessage = recorder.statusMessage
         errorMessage = recorder.errorMessage
+        outputFileURL = recorder.outputFileURL
     }
 }

+ 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
+    }()
+}

+ 238 - 0
CelestiaTrace/Views/Components/ImagePicker.swift

@@ -1,4 +1,5 @@
 import SwiftUI
+import PhotosUI
 import UIKit
 
 /// A helper SwiftUI wrapper for UIImagePickerController to select photos or capture via camera.
@@ -43,3 +44,240 @@ struct ImagePicker: UIViewControllerRepresentable {
         }
     }
 }
+
+struct PhotoRecordDraft: Identifiable {
+    let id: UUID
+    let image: UIImage
+    var note: String
+
+    init(
+        id: UUID = UUID(),
+        image: UIImage,
+        note: String = ""
+    ) {
+        self.id = id
+        self.image = image
+        self.note = note
+    }
+}
+
+struct PhotoRecordEditorSheet: View {
+    @Environment(\.dismiss) private var dismiss
+
+    let timeLabel: String
+    let onSave: ([PhotoRecordDraft], TimelineLocation?) -> Bool
+
+    @State private var drafts: [PhotoRecordDraft] = []
+    @State private var recordLocation: TimelineLocation?
+    @State private var selectedLibraryItems: [PhotosPickerItem] = []
+    @State private var showCamera = false
+    @State private var isLoadingLibrary = false
+    @State private var loadError: String?
+
+    var body: some View {
+        NavigationStack {
+            ZStack {
+                Color.spaceBlack.ignoresSafeArea()
+
+                ScrollView {
+                    VStack(alignment: .leading, spacing: 16) {
+                        Label("记录点 \(timeLabel)", systemImage: "clock")
+                            .font(.system(size: 12, weight: .medium, design: .monospaced))
+                            .foregroundStyle(Color.secondary)
+
+                        HStack(spacing: 12) {
+                            if UIImagePickerController.isSourceTypeAvailable(.camera) {
+                                Button {
+                                    showCamera = true
+                                } label: {
+                                    photoSourceButtonLabel(
+                                        title: "拍照",
+                                        systemImage: "camera"
+                                    )
+                                }
+                                .buttonStyle(.plain)
+                            }
+
+                            PhotosPicker(
+                                selection: $selectedLibraryItems,
+                                maxSelectionCount: 0,
+                                matching: .images
+                            ) {
+                                photoSourceButtonLabel(
+                                    title: "从相册选择",
+                                    systemImage: "photo.on.rectangle"
+                                )
+                            }
+                            .buttonStyle(.plain)
+                        }
+
+                        TimelineLocationButton(location: $recordLocation)
+
+                        if isLoadingLibrary {
+                            HStack(spacing: 8) {
+                                ProgressView()
+                                Text("正在读取照片…")
+                                    .font(.system(size: 12))
+                                    .foregroundStyle(Color.secondary)
+                            }
+                        }
+
+                        if drafts.isEmpty, !isLoadingLibrary {
+                            ContentUnavailableView(
+                                "还没有照片",
+                                systemImage: "photo.badge.plus",
+                                description: Text("可以连续拍照,也可以从相册一次选择多张。")
+                            )
+                            .frame(maxWidth: .infinity, minHeight: 220)
+                        } else {
+                            LazyVStack(spacing: 14) {
+                                ForEach($drafts) { $draft in
+                                    photoDraftCard(draft: $draft)
+                                }
+                            }
+                        }
+                    }
+                    .padding(20)
+                }
+            }
+            .navigationTitle("添加照片记录")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("取消") {
+                        dismiss()
+                    }
+                }
+
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("保存 \(drafts.count) 张") {
+                        if onSave(drafts, recordLocation) {
+                            dismiss()
+                        }
+                    }
+                    .disabled(drafts.isEmpty || isLoadingLibrary)
+                }
+            }
+        }
+        .fullScreenCover(isPresented: $showCamera) {
+            ImagePicker(sourceType: .camera) { image in
+                drafts.append(PhotoRecordDraft(image: image))
+            }
+        }
+        .onChange(of: selectedLibraryItems) { _, newItems in
+            guard !newItems.isEmpty else { return }
+            Task {
+                await importLibraryItems(newItems)
+            }
+        }
+        .alert("无法读取照片", isPresented: Binding(
+            get: { loadError != nil },
+            set: { if !$0 { loadError = nil } }
+        )) {
+            Button("知道了", role: .cancel) {}
+        } message: {
+            Text(loadError ?? "请重新选择照片。")
+        }
+        .presentationDetents([.large])
+        .presentationDragIndicator(.visible)
+        .presentationBackground(Color.spaceBlack)
+    }
+
+    private func photoSourceButtonLabel(
+        title: String,
+        systemImage: String
+    ) -> some View {
+        Label(title, systemImage: systemImage)
+            .font(.system(size: 13, weight: .medium))
+            .foregroundStyle(Color.primary)
+            .frame(maxWidth: .infinity)
+            .padding(.vertical, 12)
+            .background(Color.cardBackground.opacity(0.35))
+            .businessBorder(cornerRadius: 8)
+            .contentShape(Rectangle())
+    }
+
+    private func photoDraftCard(
+        draft: Binding<PhotoRecordDraft>
+    ) -> some View {
+        VStack(alignment: .leading, spacing: 10) {
+            ZStack(alignment: .topTrailing) {
+                Image(uiImage: draft.wrappedValue.image)
+                    .resizable()
+                    .scaledToFit()
+                    .frame(maxWidth: .infinity, minHeight: 160, maxHeight: 300)
+                    .clipShape(RoundedRectangle(cornerRadius: 8))
+
+                Button(role: .destructive) {
+                    let draftID = draft.wrappedValue.id
+                    drafts.removeAll { $0.id == draftID }
+                } label: {
+                    Image(systemName: "trash")
+                        .font(.system(size: 12, weight: .semibold))
+                        .foregroundStyle(.white)
+                        .frame(width: 32, height: 32)
+                        .background(.black.opacity(0.6), in: Circle())
+                }
+                .padding(8)
+                .accessibilityLabel("移除这张照片")
+            }
+
+            ZStack(alignment: .topLeading) {
+                if draft.wrappedValue.note.isEmpty {
+                    Text("照片备注")
+                        .font(.system(size: 14))
+                        .foregroundStyle(Color.secondary.opacity(0.55))
+                        .padding(.horizontal, 15)
+                        .padding(.vertical, 17)
+                        .allowsHitTesting(false)
+                }
+
+                TextEditor(text: draft.note)
+                    .font(.system(size: 14))
+                    .foregroundStyle(Color.primary)
+                    .scrollContentBackground(.hidden)
+                    .padding(8)
+                    .frame(height: 72)
+            }
+            .background(Color.cardBackground.opacity(0.45))
+            .businessBorder(cornerRadius: 8)
+
+        }
+        .padding(12)
+        .background(Color.cardBackground.opacity(0.18))
+        .businessBorder(cornerRadius: 10)
+    }
+
+    @MainActor
+    private func importLibraryItems(_ items: [PhotosPickerItem]) async {
+        isLoadingLibrary = true
+        defer {
+            isLoadingLibrary = false
+            selectedLibraryItems = []
+        }
+
+        var importedDrafts: [PhotoRecordDraft] = []
+        var failedCount = 0
+
+        for item in items {
+            do {
+                guard let data = try await item.loadTransferable(type: Data.self),
+                      let image = UIImage(data: data) else {
+                    failedCount += 1
+                    continue
+                }
+                importedDrafts.append(PhotoRecordDraft(image: image))
+            } catch {
+                failedCount += 1
+            }
+        }
+
+        drafts.append(contentsOf: importedDrafts)
+
+        if failedCount > 0 {
+            loadError = failedCount == items.count
+                ? "所选照片均无法读取,请重新选择。"
+                : "有 \(failedCount) 张照片无法读取,其余照片已添加。"
+        }
+    }
+}

+ 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
-        }
     }
 }
 

+ 580 - 0
CelestiaTrace/Views/Components/TimelineLocationPicker.swift

@@ -0,0 +1,580 @@
+import CoreLocation
+import MapKit
+import SwiftUI
+import UIKit
+
+struct TimelineLocationButton: View {
+    @Binding var location: TimelineLocation?
+    @State private var showLocationPicker = false
+
+    var body: some View {
+        Button {
+            showLocationPicker = true
+        } label: {
+            HStack(spacing: 10) {
+                Image(systemName: location == nil ? "location" : "location.fill")
+                    .font(.system(size: 13, weight: .medium))
+                    .foregroundStyle(location == nil ? Color.secondary : Color.lessCosmosGold)
+                    .frame(width: 24, height: 24)
+
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(location?.name ?? "所在位置")
+                        .font(.system(size: 13, weight: .medium))
+                        .foregroundStyle(Color.primary)
+                        .lineLimit(1)
+
+                    Text(locationSubtitle)
+                        .font(.system(size: 10))
+                        .foregroundStyle(Color.secondary)
+                        .lineLimit(1)
+                }
+
+                Spacer(minLength: 8)
+
+                Image(systemName: "chevron.right")
+                    .font(.system(size: 9, weight: .medium))
+                    .foregroundStyle(Color.secondary.opacity(0.6))
+            }
+            .padding(.horizontal, 12)
+            .padding(.vertical, 10)
+            .background(Color.cardBackground.opacity(0.35))
+            .businessBorder(cornerRadius: 8)
+            .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+        .accessibilityLabel(location == nil ? "添加所在位置" : "修改所在位置")
+        .accessibilityValue(location?.name ?? "尚未添加")
+        .sheet(isPresented: $showLocationPicker) {
+            TimelineLocationPickerSheet(selection: $location)
+        }
+    }
+
+    private var locationSubtitle: String {
+        guard let location else { return "点击后根据 GPS 选择当前位置" }
+        return location.subtitle.isEmpty ? "已添加位置" : location.subtitle
+    }
+}
+
+struct TimelineLocationPickerSheet: View {
+    @Environment(\.dismiss) private var dismiss
+    @Binding var selection: TimelineLocation?
+
+    @StateObject private var locationService = TimelineLocationService()
+    @State private var selectedLocation: TimelineLocation?
+    @State private var nearbyLocations: [TimelineLocation] = []
+    @State private var searchResults: [TimelineLocation] = []
+    @State private var cameraPosition: MapCameraPosition = .automatic
+    @State private var searchText = ""
+    @State private var isLoadingPlaces = false
+    @State private var placeError: String?
+    @State private var hasMadeSelection = false
+
+    init(selection: Binding<TimelineLocation?>) {
+        _selection = selection
+        _selectedLocation = State(initialValue: selection.wrappedValue)
+    }
+
+    var body: some View {
+        NavigationStack {
+            VStack(spacing: 0) {
+                locationMap
+                    .frame(height: 220)
+
+                Divider()
+                    .overlay(Color.lineBorder)
+
+                locationList
+            }
+            .background(Color.spaceBlack)
+            .navigationTitle("所在位置")
+            .navigationBarTitleDisplayMode(.inline)
+            .searchable(
+                text: $searchText,
+                placement: .navigationBarDrawer(displayMode: .always),
+                prompt: "搜索附近地点"
+            )
+            .onSubmit(of: .search) {
+                Task {
+                    await searchPlaces()
+                }
+            }
+            .onChange(of: searchText) { _, newValue in
+                if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+                    searchResults = []
+                    placeError = nil
+                }
+            }
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("取消") {
+                        dismiss()
+                    }
+                }
+
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("完成") {
+                        selection = selectedLocation
+                        dismiss()
+                    }
+                }
+            }
+        }
+        .onAppear {
+            locationService.requestCurrentLocation()
+            if let selectedLocation {
+                centerMap(on: selectedLocation)
+            }
+        }
+        .onChange(of: locationService.currentLocation) { _, location in
+            guard let location else { return }
+            if selectedLocation == nil, !hasMadeSelection {
+                selectedLocation = location
+            }
+            centerMap(on: selectedLocation ?? location)
+            Task {
+                await loadNearbyPlaces(around: location)
+            }
+        }
+        .presentationDetents([.large])
+        .presentationDragIndicator(.visible)
+        .presentationBackground(Color.spaceBlack)
+    }
+
+    private var locationMap: some View {
+        Map(position: $cameraPosition) {
+            UserAnnotation()
+
+            if let selectedLocation {
+                Marker(
+                    selectedLocation.name,
+                    coordinate: coordinate(for: selectedLocation)
+                )
+                .tint(Color.lessCosmosGold)
+            }
+        }
+        .mapStyle(.standard(pointsOfInterest: .all))
+        .overlay(alignment: .bottomTrailing) {
+            Button {
+                locationService.requestCurrentLocation()
+            } label: {
+                Image(systemName: "location.fill")
+                    .font(.system(size: 14, weight: .semibold))
+                    .foregroundStyle(Color.primary)
+                    .frame(width: 38, height: 38)
+                    .background(.ultraThinMaterial, in: Circle())
+            }
+            .padding(12)
+            .accessibilityLabel("重新定位")
+        }
+    }
+
+    private var locationList: some View {
+        ScrollView {
+            LazyVStack(spacing: 0) {
+                noLocationRow
+
+                if let currentLocation = locationService.currentLocation {
+                    locationRow(
+                        currentLocation,
+                        icon: "location.fill",
+                        iconColor: Color.lessCosmosGold
+                    )
+                } else {
+                    locatingRow
+                }
+
+                if isLoadingPlaces {
+                    HStack(spacing: 10) {
+                        ProgressView()
+                        Text(searchText.isEmpty ? "正在查找附近地点…" : "正在搜索地点…")
+                            .font(.system(size: 12))
+                            .foregroundStyle(Color.secondary)
+                        Spacer()
+                    }
+                    .padding(.horizontal, 20)
+                    .padding(.vertical, 16)
+                }
+
+                ForEach(displayedLocations, id: \.self) { location in
+                    locationRow(location)
+                }
+
+                if let message = locationService.errorMessage ?? placeError {
+                    locationErrorRow(message)
+                }
+            }
+        }
+        .scrollDismissesKeyboard(.interactively)
+    }
+
+    private var noLocationRow: some View {
+        Button {
+            hasMadeSelection = true
+            selectedLocation = nil
+        } label: {
+            HStack(spacing: 12) {
+                Image(systemName: "location.slash")
+                    .font(.system(size: 13, weight: .medium))
+                    .foregroundStyle(Color.secondary)
+                    .frame(width: 28)
+
+                Text("不显示位置")
+                    .font(.system(size: 14, weight: .medium))
+                    .foregroundStyle(Color.primary)
+
+                Spacer()
+
+                if selectedLocation == nil {
+                    Image(systemName: "checkmark")
+                        .font(.system(size: 13, weight: .semibold))
+                        .foregroundStyle(Color.lessCosmosGold)
+                }
+            }
+            .padding(.horizontal, 20)
+            .padding(.vertical, 15)
+            .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+        .overlay(alignment: .bottom) {
+            Divider()
+                .padding(.leading, 60)
+                .overlay(Color.lineBorder)
+        }
+    }
+
+    private var locatingRow: some View {
+        HStack(spacing: 12) {
+            if locationService.isLocating {
+                ProgressView()
+                    .frame(width: 28)
+            } else {
+                Image(systemName: "location")
+                    .foregroundStyle(Color.secondary)
+                    .frame(width: 28)
+            }
+
+            VStack(alignment: .leading, spacing: 3) {
+                Text(locationService.isLocating ? "正在定位…" : "当前位置")
+                    .font(.system(size: 14, weight: .medium))
+                    .foregroundStyle(Color.primary)
+
+                Text("用于查找你附近的地点")
+                    .font(.system(size: 11))
+                    .foregroundStyle(Color.secondary)
+            }
+
+            Spacer()
+        }
+        .padding(.horizontal, 20)
+        .padding(.vertical, 14)
+    }
+
+    private func locationRow(
+        _ location: TimelineLocation,
+        icon: String = "mappin.and.ellipse",
+        iconColor: Color = Color.secondary
+    ) -> some View {
+        Button {
+            hasMadeSelection = true
+            selectedLocation = location
+            centerMap(on: location)
+        } label: {
+            HStack(spacing: 12) {
+                Image(systemName: icon)
+                    .font(.system(size: 13, weight: .medium))
+                    .foregroundStyle(iconColor)
+                    .frame(width: 28)
+
+                VStack(alignment: .leading, spacing: 3) {
+                    Text(location.name)
+                        .font(.system(size: 14, weight: .medium))
+                        .foregroundStyle(Color.primary)
+                        .lineLimit(1)
+
+                    if !location.subtitle.isEmpty {
+                        Text(location.subtitle)
+                            .font(.system(size: 11))
+                            .foregroundStyle(Color.secondary)
+                            .lineLimit(2)
+                    }
+                }
+
+                Spacer(minLength: 8)
+
+                if selectedLocation == location {
+                    Image(systemName: "checkmark")
+                        .font(.system(size: 13, weight: .semibold))
+                        .foregroundStyle(Color.lessCosmosGold)
+                }
+            }
+            .padding(.horizontal, 20)
+            .padding(.vertical, 13)
+            .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+        .overlay(alignment: .bottom) {
+            Divider()
+                .padding(.leading, 60)
+                .overlay(Color.lineBorder.opacity(0.7))
+        }
+    }
+
+    private func locationErrorRow(_ message: String) -> some View {
+        VStack(alignment: .leading, spacing: 10) {
+            Text(message)
+                .font(.system(size: 12))
+                .foregroundStyle(Color.secondary)
+
+            if locationService.isAuthorizationDenied {
+                Button("前往系统设置") {
+                    guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
+                        return
+                    }
+                    UIApplication.shared.open(settingsURL)
+                }
+                .font(.system(size: 12, weight: .semibold))
+            } else {
+                Button("重新定位") {
+                    locationService.requestCurrentLocation()
+                }
+                .font(.system(size: 12, weight: .semibold))
+            }
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .padding(20)
+    }
+
+    private var displayedLocations: [TimelineLocation] {
+        searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+            ? nearbyLocations
+            : searchResults
+    }
+
+    @MainActor
+    private func loadNearbyPlaces(around location: TimelineLocation) async {
+        isLoadingPlaces = true
+        placeError = nil
+        defer { isLoadingPlaces = false }
+
+        let request = MKLocalPointsOfInterestRequest(
+            center: coordinate(for: location),
+            radius: 2_000
+        )
+        request.pointOfInterestFilter = .includingAll
+
+        do {
+            let response = try await MKLocalSearch(request: request).start()
+            nearbyLocations = uniqueLocations(
+                response.mapItems.compactMap(location(from:))
+            )
+        } catch {
+            placeError = "附近地点暂时无法加载,你仍可使用 GPS 当前位置。"
+        }
+    }
+
+    @MainActor
+    private func searchPlaces() async {
+        let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !query.isEmpty,
+              let center = locationService.currentLocation ?? selectedLocation else {
+            return
+        }
+
+        isLoadingPlaces = true
+        placeError = nil
+        defer { isLoadingPlaces = false }
+
+        let request = MKLocalSearch.Request()
+        request.naturalLanguageQuery = query
+        request.region = MKCoordinateRegion(
+            center: coordinate(for: center),
+            latitudinalMeters: 10_000,
+            longitudinalMeters: 10_000
+        )
+
+        do {
+            let response = try await MKLocalSearch(request: request).start()
+            searchResults = uniqueLocations(
+                response.mapItems.compactMap(location(from:))
+            )
+            if searchResults.isEmpty {
+                placeError = "没有找到相关地点,请换一个关键词。"
+            }
+        } catch {
+            placeError = "地点搜索失败,请稍后重试。"
+        }
+    }
+
+    private func location(from mapItem: MKMapItem) -> TimelineLocation? {
+        guard let name = mapItem.name?
+            .trimmingCharacters(in: .whitespacesAndNewlines),
+              !name.isEmpty else {
+            return nil
+        }
+
+        let coordinate = mapItem.placemark.coordinate
+        return TimelineLocation(
+            name: name,
+            address: mapItem.placemark.title ?? "",
+            latitude: coordinate.latitude,
+            longitude: coordinate.longitude
+        )
+    }
+
+    private func uniqueLocations(
+        _ locations: [TimelineLocation]
+    ) -> [TimelineLocation] {
+        var keys = Set<String>()
+        return locations.filter { location in
+            let key = [
+                location.name,
+                String(format: "%.5f", location.latitude),
+                String(format: "%.5f", location.longitude)
+            ].joined(separator: "|")
+            return keys.insert(key).inserted
+        }
+    }
+
+    private func centerMap(on location: TimelineLocation) {
+        cameraPosition = .region(
+            MKCoordinateRegion(
+                center: coordinate(for: location),
+                latitudinalMeters: 1_200,
+                longitudinalMeters: 1_200
+            )
+        )
+    }
+
+    private func coordinate(
+        for location: TimelineLocation
+    ) -> CLLocationCoordinate2D {
+        CLLocationCoordinate2D(
+            latitude: location.latitude,
+            longitude: location.longitude
+        )
+    }
+}
+
+private final class TimelineLocationService: NSObject, ObservableObject {
+    @Published private(set) var currentLocation: TimelineLocation?
+    @Published private(set) var isLocating = false
+    @Published private(set) var errorMessage: String?
+    @Published private(set) var authorizationStatus: CLAuthorizationStatus
+
+    private let manager = CLLocationManager()
+    private let geocoder = CLGeocoder()
+
+    override init() {
+        authorizationStatus = manager.authorizationStatus
+        super.init()
+        manager.delegate = self
+        manager.desiredAccuracy = kCLLocationAccuracyBest
+    }
+
+    var isAuthorizationDenied: Bool {
+        authorizationStatus == .denied || authorizationStatus == .restricted
+    }
+
+    func requestCurrentLocation() {
+        errorMessage = nil
+
+        switch manager.authorizationStatus {
+        case .notDetermined:
+            isLocating = true
+            manager.requestWhenInUseAuthorization()
+        case .authorizedAlways, .authorizedWhenInUse:
+            isLocating = true
+            manager.requestLocation()
+        case .denied, .restricted:
+            isLocating = false
+            errorMessage = "定位权限未开启。你可以前往系统设置允许访问当前位置。"
+        @unknown default:
+            isLocating = false
+            errorMessage = "暂时无法确认定位权限。"
+        }
+    }
+}
+
+extension TimelineLocationService: CLLocationManagerDelegate {
+    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
+        authorizationStatus = manager.authorizationStatus
+        if manager.authorizationStatus == .authorizedAlways
+            || manager.authorizationStatus == .authorizedWhenInUse {
+            isLocating = true
+            manager.requestLocation()
+        } else if manager.authorizationStatus == .denied
+            || manager.authorizationStatus == .restricted {
+            isLocating = false
+            errorMessage = "定位权限未开启。你可以前往系统设置允许访问当前位置。"
+        }
+    }
+
+    func locationManager(
+        _ manager: CLLocationManager,
+        didUpdateLocations locations: [CLLocation]
+    ) {
+        guard let location = locations.last else { return }
+        isLocating = false
+
+        geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
+            DispatchQueue.main.async {
+                guard let self else { return }
+
+                if let placemark = placemarks?.first {
+                    self.currentLocation = TimelineLocation(
+                        name: "当前位置",
+                        address: Self.address(from: placemark),
+                        latitude: location.coordinate.latitude,
+                        longitude: location.coordinate.longitude
+                    )
+                } else {
+                    self.currentLocation = TimelineLocation(
+                        name: "当前位置",
+                        address: String(
+                            format: "%.6f, %.6f",
+                            location.coordinate.latitude,
+                            location.coordinate.longitude
+                        ),
+                        latitude: location.coordinate.latitude,
+                        longitude: location.coordinate.longitude
+                    )
+                    if error != nil {
+                        self.errorMessage = "已取得 GPS 位置,但暂时无法解析详细地址。"
+                    }
+                }
+            }
+        }
+    }
+
+    func locationManager(
+        _ manager: CLLocationManager,
+        didFailWithError error: Error
+    ) {
+        isLocating = false
+        if (error as? CLError)?.code == .locationUnknown {
+            errorMessage = "暂时无法确定当前位置,请移到开阔区域后重试。"
+        } else {
+            errorMessage = "定位失败,请检查系统定位服务后重试。"
+        }
+    }
+
+    private static func address(from placemark: CLPlacemark) -> String {
+        var parts: [String] = []
+        for part in [
+            placemark.administrativeArea,
+            placemark.locality,
+            placemark.subLocality,
+            placemark.thoroughfare,
+            placemark.subThoroughfare,
+            placemark.name
+        ] {
+            guard let part = part?.trimmingCharacters(in: .whitespacesAndNewlines),
+                  !part.isEmpty,
+                  !parts.contains(part) else {
+                continue
+            }
+            parts.append(part)
+        }
+        return parts.joined(separator: " ")
+    }
+}

+ 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"
             }
         }
     }

+ 65 - 15
CelestiaTrace/Views/Detail/ChronoFeedRow.swift

@@ -6,6 +6,7 @@ import SwiftUI
 /// and a clean, wireframe bordered layout suitable for business scenarios.
 struct ChronoFeedRow: View {
     let event: CelestiaTimelineEvent
+    var photoEvents: [CelestiaTimelineEvent] = []
     var onTap: (() -> Void)?
 
     var body: some View {
@@ -79,6 +80,13 @@ struct ChronoFeedRow: View {
                         .font(.system(size: 13))
                         .foregroundStyle(Color.secondary)
                 }
+
+                if let location = displayLocation {
+                    Label(location.name, systemImage: "location.fill")
+                        .font(.system(size: 10, weight: .medium))
+                        .foregroundStyle(Color.secondary)
+                        .lineLimit(1)
+                }
             }
         }
     }
@@ -101,22 +109,39 @@ struct ChronoFeedRow: View {
 
     @ViewBuilder
     private var photoView: some View {
-        if let path = event.localFilePath,
-           let uiImage = UIImage(contentsOfFile: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent(path).path) {
+        let events = displayPhotoEvents
+        let images = events.compactMap(resolveImage(for:))
+
+        if !images.isEmpty {
             HStack(spacing: 8) {
-                Image(uiImage: uiImage)
-                    .resizable()
-                    .scaledToFill()
-                    .frame(width: 60, height: 45)
-                    .clipShape(RoundedRectangle(cornerRadius: 4))
-                    .overlay(
-                        RoundedRectangle(cornerRadius: 4)
-                            .stroke(Color.lineBorder, lineWidth: 1)
-                    )
-                
-                Text("照片记录")
-                    .font(.system(size: 11))
-                    .foregroundStyle(Color.secondary)
+                ZStack(alignment: .leading) {
+                    ForEach(Array(images.prefix(3).enumerated()), id: \.offset) { index, image in
+                        Image(uiImage: image)
+                            .resizable()
+                            .scaledToFill()
+                            .frame(width: 48, height: 40)
+                            .clipShape(RoundedRectangle(cornerRadius: 4))
+                            .overlay {
+                                RoundedRectangle(cornerRadius: 4)
+                                    .stroke(Color.lineBorder, lineWidth: 1)
+                            }
+                            .offset(x: CGFloat(index) * 12)
+                    }
+                }
+                .frame(width: images.count > 1 ? 72 : 48, height: 40, alignment: .leading)
+
+                VStack(alignment: .leading, spacing: 3) {
+                    Text(events.count > 1 ? "\(events.count) 张照片" : "照片记录")
+                        .font(.system(size: 11))
+                        .foregroundStyle(Color.secondary)
+
+                    if let note = firstPhotoNote {
+                        Text(note)
+                            .font(.system(size: 12))
+                            .foregroundStyle(Color.primary.opacity(0.9))
+                            .lineLimit(1)
+                    }
+                }
             }
         } else {
             photoPlaceholder
@@ -152,6 +177,31 @@ struct ChronoFeedRow: View {
         default: return "事件"
         }
     }
+
+    private var displayPhotoEvents: [CelestiaTimelineEvent] {
+        photoEvents.isEmpty ? [event] : photoEvents
+    }
+
+    private var firstPhotoNote: String? {
+        displayPhotoEvents
+            .compactMap(\.textContent)
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .first { !$0.isEmpty }
+    }
+
+    private var displayLocation: TimelineLocation? {
+        if event.eventType == "PHOTO" {
+            return displayPhotoEvents.compactMap(\.location).first
+        }
+        return event.location
+    }
+
+    private func resolveImage(for event: CelestiaTimelineEvent) -> UIImage? {
+        guard let url = AudioPathHelper.resolveURL(for: event.localFilePath) else {
+            return nil
+        }
+        return UIImage(contentsOfFile: url.path)
+    }
 }
 
 // MARK: - Preview

+ 46 - 10
CelestiaTrace/Views/Detail/MultiTrackTimeline.swift

@@ -13,6 +13,13 @@ struct MultiTrackTimeline: View {
         case note
     }
 
+    private struct PhotoEventGroup: Identifiable {
+        let relativeTimeMs: Int64
+        let events: [CelestiaTimelineEvent]
+
+        var id: UUID { events[0].id }
+    }
+
     let events: [CelestiaTimelineEvent]
     @Binding var currentTimeMs: Double
     let totalDurationMs: Double
@@ -226,20 +233,33 @@ struct MultiTrackTimeline: View {
 
             trackLeadingIcon(systemName: "camera", accessibilityLabel: "图片轨道")
 
-            ForEach(photoEvents) { event in
+            ForEach(photoEventGroups) { group in
                 Button {
-                    onEventTap?(event)
+                    onEventTap?(group.events[0])
                 } label: {
-                    Image(systemName: "camera")
-                        .font(.system(size: 9))
-                        .foregroundStyle(Color.primary.opacity(0.8))
-                        .frame(width: 20, height: 20)
-                        .background(Color.cardBackground.opacity(0.6))
-                        .businessBorder(cornerRadius: 10)
+                    ZStack(alignment: .topTrailing) {
+                        Image(systemName: "camera")
+                            .font(.system(size: 9))
+                            .foregroundStyle(Color.primary.opacity(0.8))
+                            .frame(width: 20, height: 20)
+                            .background(Color.cardBackground.opacity(0.6))
+                            .businessBorder(cornerRadius: 10)
+
+                        if group.events.count > 1 {
+                            Text("\(group.events.count)")
+                                .font(.system(size: 7, weight: .bold, design: .rounded))
+                                .foregroundStyle(Color.spaceBlack)
+                                .frame(minWidth: 13, minHeight: 13)
+                                .background(Color.primary, in: Circle())
+                                .offset(x: 5, y: -5)
+                        }
+                    }
                 }
                 .buttonStyle(.plain)
-                .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
-                .accessibilityLabel("查看 \(event.relativeTimeFormatted) 的图片")
+                .offset(x: xPosition(for: Double(group.relativeTimeMs)) - 10)
+                .accessibilityLabel(
+                    "查看 \(group.events[0].relativeTimeFormatted) 的 \(group.events.count) 张图片"
+                )
             }
         }
     }
@@ -403,6 +423,22 @@ struct MultiTrackTimeline: View {
         events.filter { $0.eventType == "PHOTO" }
     }
 
+    private var photoEventGroups: [PhotoEventGroup] {
+        Dictionary(grouping: photoEvents, by: \.relativeTimeMs)
+            .map { relativeTimeMs, events in
+                PhotoEventGroup(
+                    relativeTimeMs: relativeTimeMs,
+                    events: events.sorted {
+                        if $0.createdAt == $1.createdAt {
+                            return $0.id.uuidString < $1.id.uuidString
+                        }
+                        return $0.createdAt < $1.createdAt
+                    }
+                )
+            }
+            .sorted { $0.relativeTimeMs < $1.relativeTimeMs }
+    }
+
     private var noteEvents: [CelestiaTimelineEvent] {
         events.filter {
             $0.eventType == "NOTE"

Fișier diff suprimat deoarece este prea mare
+ 642 - 103
CelestiaTrace/Views/Detail/SessionDetailView.swift


+ 97 - 23
CelestiaTrace/Views/History/SessionListView.swift → CelestiaTrace/Views/FieldRecords/SessionListView.swift

@@ -8,14 +8,20 @@ import SwiftData
 struct SessionListView: View {
     @Environment(\.modelContext) private var modelContext
     @Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession]
+    @ObservedObject private var authManager = AuthManager.shared
 
+    @Binding var selectedSession: CelestiaSession?
     var onStartRecording: () -> Void = {}
 
     @State private var searchText = ""
     @State private var deletionError: String?
     @State private var sessionPendingDeletion: CelestiaSession?
-    @State private var selectedSession: CelestiaSession?
-    @State private var showSessionDetail = false
+    @State private var deletingSessionID: UUID?
+
+    init(selectedSession: Binding<CelestiaSession?> = .constant(nil), onStartRecording: @escaping () -> Void = {}) {
+        self._selectedSession = selectedSession
+        self.onStartRecording = onStartRecording
+    }
 
     var body: some View {
         NavigationStack {
@@ -28,13 +34,11 @@ struct SessionListView: View {
                     sessionList
                 }
             }
-            .navigationTitle("历史记录")
+            .navigationTitle("现场记录")
             .navigationBarTitleDisplayMode(.inline)
             .modifier(SessionSearchModifier(isEnabled: !sessions.isEmpty, searchText: $searchText))
-            .navigationDestination(isPresented: $showSessionDetail) {
-                if let selectedSession {
-                    SessionDetailView(session: selectedSession)
-                }
+            .navigationDestination(item: $selectedSession) { session in
+                SessionDetailView(session: session)
             }
             .alert("删除失败", isPresented: Binding(
                 get: { deletionError != nil },
@@ -53,12 +57,19 @@ struct SessionListView: View {
                 presenting: sessionPendingDeletion
             ) { session in
                 Button("取消", role: .cancel) {}
-                Button("删除", role: .destructive) {
-                    deleteSession(session)
+                Button(session.cloudSessionId == nil ? "删除" : "同时删除", role: .destructive) {
                     sessionPendingDeletion = nil
+                    Task {
+                        await deleteSession(session)
+                    }
                 }
+                .disabled(deletingSessionID != nil)
             } message: { session in
-                Text("删除「\(session.title)」后,相关录音和图片也会被删除,且无法恢复。")
+                if session.cloudSessionId != nil {
+                    Text("删除「\(session.title)」后,本地录音、图片以及已经同步到云端的记录都会一并删除,且无法恢复。")
+                } else {
+                    Text("删除「\(session.title)」后,相关录音和图片也会被删除,且无法恢复。")
+                }
             }
         }
     }
@@ -83,7 +94,6 @@ struct SessionListView: View {
             ForEach(filteredSessions) { session in
                 Button {
                     selectedSession = session
-                    showSessionDetail = true
                 } label: {
                     SessionRowCard(session: session)
                         .contentShape(Rectangle())
@@ -105,15 +115,34 @@ struct SessionListView: View {
         .listStyle(.plain)
         .scrollContentBackground(.hidden)
         .contentMargins(.bottom, 18, for: .scrollContent)
+        .refreshable {
+            await synchronizeFieldRecords()
+        }
     }
 
-    private func deleteSession(_ session: CelestiaSession) {
+    @MainActor
+    private func deleteSession(_ session: CelestiaSession) async {
+        guard deletingSessionID == nil else { return }
+        deletingSessionID = session.id
+        defer { deletingSessionID = nil }
+
         let storedPaths =
             [session.localAudioPath].compactMap { $0 } +
             session.audioChunks.map(\.localFilePath) +
             session.events.compactMap(\.localFilePath)
         let fileURLs = Set(storedPaths.compactMap { AudioPathHelper.resolveURL(for: $0) })
 
+        if let cloudSessionID = session.cloudSessionId {
+            do {
+                try await RemoteNetworkService().deleteSession(sessionID: cloudSessionID)
+            } catch APIError.server(let code, _) where code == 404 {
+                // The remote record is already gone, so local cleanup can continue.
+            } catch {
+                deletionError = "云端记录删除失败,本地记录未删除:\(error.localizedDescription)"
+                return
+            }
+        }
+
         modelContext.delete(session)
 
         do {
@@ -131,14 +160,24 @@ struct SessionListView: View {
 
     @ViewBuilder
     private var emptyState: some View {
-        if sessions.isEmpty {
-            Button(action: onStartRecording) {
-                emptyStateContent
-                    .contentShape(Rectangle())
+        GeometryReader { proxy in
+            ScrollView {
+                Group {
+                    if sessions.isEmpty {
+                        Button(action: onStartRecording) {
+                            emptyStateContent
+                                .contentShape(Rectangle())
+                        }
+                        .buttonStyle(.plain)
+                    } else {
+                        emptyStateContent
+                    }
+                }
+                .frame(minHeight: proxy.size.height)
+            }
+            .refreshable {
+                await synchronizeFieldRecords()
             }
-            .buttonStyle(.plain)
-        } else {
-            emptyStateContent
         }
     }
 
@@ -165,6 +204,16 @@ struct SessionListView: View {
         .frame(maxWidth: .infinity, maxHeight: .infinity)
         .padding(.bottom, 60)
     }
+
+    @MainActor
+    private func synchronizeFieldRecords() async {
+        guard let userID = authManager.currentUser?.id else { return }
+        _ = await SyncManager.shared.sync(
+            sessions: sessions,
+            modelContext: modelContext,
+            userID: userID
+        )
+    }
 }
 
 private struct SessionSearchModifier: ViewModifier {
@@ -188,14 +237,13 @@ private struct SessionSearchModifier: ViewModifier {
 // MARK: - Session Row Card
 
 private struct SessionRowCard: View {
+    @ObservedObject private var syncManager: SyncManager = .shared
+
     let session: CelestiaSession
 
     var body: some View {
         HStack(spacing: 12) {
-            // Sync status indicator (plain circular dot)
-            Circle()
-                .fill(session.isSynced ? Color.secondary.opacity(0.4) : Color.lessCosmosGold)
-                .frame(width: 6, height: 6)
+            syncStatusIndicator
 
             VStack(alignment: .leading, spacing: 4) {
                 Text(session.title)
@@ -232,6 +280,32 @@ private struct SessionRowCard: View {
         .businessBorder(cornerRadius: 8)
     }
 
+    @ViewBuilder
+    private var syncStatusIndicator: some View {
+        if isThisSessionSyncing {
+            ProgressView()
+                .controlSize(.mini)
+                .tint(Color.lessCosmosGold)
+                .scaleEffect(0.7)
+                .frame(width: 10, height: 10)
+                .accessibilityLabel("正在同步")
+        } else {
+            Circle()
+                .fill(
+                    session.hasConfirmedCloudSync
+                        ? Color.lessCosmosGold
+                        : Color.secondary.opacity(0.4)
+                )
+                .frame(width: 6, height: 6)
+                .frame(width: 10, height: 10)
+                .accessibilityLabel(session.hasConfirmedCloudSync ? "已同步" : "未同步")
+        }
+    }
+
+    private var isThisSessionSyncing: Bool {
+        syncManager.isSyncing && syncManager.activeSessionID == session.id
+    }
+
     private func countBadge(icon: String, count: Int) -> some View {
         HStack(spacing: 3) {
             Image(systemName: icon)

+ 208 - 8
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)
-            }
         }
     }
 
@@ -161,14 +166,111 @@ struct HomeView: View {
     private func createSessionAndNavigate(source: RecordingSourceChoice) {
         let formatter = DateFormatter()
         formatter.dateFormat = "yyyyMMdd_HHmm"
-        let title = "现场记录_\(formatter.string(from: Date()))"
+        let title = "\(formatter.string(from: Date()))_现场记录"
 
         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()
+    }
 }

+ 203 - 49
CelestiaTrace/Views/Recording/ActiveRecordingView.swift

@@ -1,5 +1,6 @@
 import SwiftUI
 import SwiftData
+import UIKit
 
 // MARK: - ActiveRecordingView
 /// The live recording screen showing real-time waveform, elapsed time,
@@ -8,38 +9,47 @@ import SwiftData
 struct ActiveRecordingView: View {
     @Environment(\.modelContext) private var modelContext
     @Environment(\.dismiss) private var dismiss
+    @ObservedObject private var syncManager: SyncManager = .shared
 
     let session: CelestiaSession
     var initialDuration: TimeInterval = 0
     let recordingSource: RecordingSourceChoice
+    var initialAction: RecordingLiveActivityAction? = nil
     var onFinishRecording: (() -> Void)? = nil
 
     @State private var recordingVM: RecordingViewModel
     @State private var showNoteSheet = false
-    @State private var showCamera = false
+    @State private var showPhotoEditor = false
     @State private var noteText = ""
+    @State private var noteLocation: TimelineLocation?
     @State private var pendingNoteTimeMs: Double = 0
+    @State private var pendingPhotoTimeMs: Double = 0
     @State private var latestEvent: CelestiaTimelineEvent?
     @State private var latestEventVisible = false
     @State private var isEndingRecording = false
     @State private var stopErrorMessage: String?
+    @State private var photoSaveError: String?
     @State private var recordName: String
     @State private var showRecordNameEditor = false
     @State private var hasStartedLiveActivity = false
     @State private var hasAddedContinuationMarker = false
+    @State private var pendingInitialAction: RecordingLiveActivityAction?
 
     init(
         session: CelestiaSession,
         initialDuration: TimeInterval = 0,
         recordingSource: RecordingSourceChoice = .iPhone,
+        initialAction: RecordingLiveActivityAction? = nil,
         onFinishRecording: (() -> Void)? = nil
     ) {
         self.session = session
         self.initialDuration = initialDuration
         self.recordingSource = recordingSource
+        self.initialAction = initialAction
         self.onFinishRecording = onFinishRecording
         _recordingVM = State(initialValue: RecordingViewModel(source: recordingSource))
         _recordName = State(initialValue: session.title)
+        _pendingInitialAction = State(initialValue: initialAction)
     }
 
     var body: some View {
@@ -100,9 +110,11 @@ struct ActiveRecordingView: View {
         .sheet(isPresented: $showNoteSheet) {
             noteInputSheet
         }
-        .fullScreenCover(isPresented: $showCamera) {
-            ImagePicker(sourceType: UIImagePickerController.isSourceTypeAvailable(.camera) ? .camera : .photoLibrary) { image in
-                saveImageAndAddEvent(image)
+        .sheet(isPresented: $showPhotoEditor) {
+            PhotoRecordEditorSheet(
+                timeLabel: formattedNoteTime(pendingPhotoTimeMs)
+            ) { drafts, location in
+                savePhotosAndAddEvents(drafts, location: location)
             }
         }
         .onAppear {
@@ -112,6 +124,7 @@ struct ActiveRecordingView: View {
         .onChange(of: recordingVM.isRecording) { _, isRecording in
             if isRecording {
                 addContinuationMarkerIfNeeded()
+                performPendingInitialActionIfReady()
             }
             guard isRecording, !hasStartedLiveActivity else { return }
             hasStartedLiveActivity = true
@@ -122,6 +135,10 @@ struct ActiveRecordingView: View {
                 elapsedSeconds: recordingVM.elapsedTime
             )
         }
+        .onChange(of: recordingVM.outputFileURL) { _, outputURL in
+            guard let outputURL else { return }
+            RecordingRecoveryStore.setPendingSegment(outputURL, for: session.id)
+        }
         .onChange(of: recordingVM.isPaused) { _, isPaused in
             guard hasStartedLiveActivity else { return }
             RecordingLiveActivityManager.shared.update(
@@ -152,6 +169,14 @@ struct ActiveRecordingView: View {
         } message: {
             Text(stopErrorMessage ?? "请确认微光仍在附近并保持连接。")
         }
+        .alert("照片保存失败", isPresented: Binding(
+            get: { photoSaveError != nil },
+            set: { if !$0 { photoSaveError = nil } }
+        )) {
+            Button("知道了", role: .cancel) {}
+        } message: {
+            Text(photoSaveError ?? "请稍后重试。")
+        }
     }
 
     // MARK: - Record Name
@@ -447,7 +472,7 @@ struct ActiveRecordingView: View {
 
                     ZStack(alignment: .topLeading) {
                         if noteText.isEmpty {
-                            Text("输入笔记内容…")
+                            Text("这一刻的想法")
                                 .font(.system(size: 15))
                                 .foregroundStyle(Color.secondary.opacity(0.6))
                                 .padding(.horizontal, 18)
@@ -465,6 +490,8 @@ struct ActiveRecordingView: View {
                             .businessBorder(cornerRadius: 8)
                     }
 
+                    TimelineLocationButton(location: $noteLocation)
+
                     Spacer()
                 }
                 .padding(20)
@@ -476,6 +503,7 @@ struct ActiveRecordingView: View {
                     Button("取消") {
                         showNoteSheet = false
                         noteText = ""
+                        noteLocation = nil
                     }
                 }
 
@@ -495,22 +523,61 @@ struct ActiveRecordingView: View {
     // MARK: - Actions
 
     private func capturePhoto() {
-        showCamera = true
+        pendingPhotoTimeMs = max(0, recordingVM.elapsedTime * 1_000)
+        showPhotoEditor = true
     }
 
-    private func saveImageAndAddEvent(_ image: UIImage) {
-        guard let data = image.jpegData(compressionQuality: 0.8) else { return }
-        let filename = "photo_\(UUID().uuidString).jpg"
-        let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
-        let fileURL = documentsURL.appendingPathComponent(filename)
+    private func savePhotosAndAddEvents(
+        _ drafts: [PhotoRecordDraft],
+        location: TimelineLocation?
+    ) -> Bool {
+        guard !drafts.isEmpty,
+              let documentsURL = FileManager.default.urls(
+                for: .documentDirectory,
+                in: .userDomainMask
+              ).first else {
+            photoSaveError = "无法访问本地照片目录。"
+            return false
+        }
+
+        var storedPhotos: [(filename: String, fileURL: URL, note: String)] = []
+
         do {
-            try data.write(to: fileURL)
-            let event = recordingVM.addPhotoEvent(to: session, localFilePath: filename)
-            HapticManager.trigger(.photoCapture)
-            showLatestEvent(event)
+            for draft in drafts {
+                guard let data = draft.image.jpegData(compressionQuality: 0.82) else {
+                    throw CocoaError(.fileWriteUnknown)
+                }
+
+                let filename = "photo_\(UUID().uuidString).jpg"
+                let fileURL = documentsURL.appendingPathComponent(filename)
+                try data.write(to: fileURL, options: .atomic)
+                storedPhotos.append((filename, fileURL, draft.note))
+            }
         } catch {
-            print("[ActiveRecordingView] Failed to save captured photo: \(error.localizedDescription)")
+            for storedPhoto in storedPhotos {
+                try? FileManager.default.removeItem(at: storedPhoto.fileURL)
+            }
+            photoSaveError = "照片文件写入失败:\(error.localizedDescription)"
+            return false
+        }
+
+        let relativeTimeMs = Int64(pendingPhotoTimeMs.rounded())
+        let events = storedPhotos.map { storedPhoto in
+            let event = recordingVM.addPhotoEvent(
+                to: session,
+                relativeTimeMs: relativeTimeMs,
+                localFilePath: storedPhoto.filename,
+                note: storedPhoto.note
+            )
+            event.location = location
+            return event
+        }
+
+        HapticManager.trigger(.photoCapture)
+        if let latestPhotoEvent = events.last {
+            showLatestEvent(latestPhotoEvent)
         }
+        return true
     }
 
     private func addNote() {
@@ -519,11 +586,13 @@ struct ActiveRecordingView: View {
 
         let event = recordingVM.addNoteEvent(to: session, text: text)
         event.relativeTimeMs = Int64(pendingNoteTimeMs.rounded())
+        event.location = noteLocation
 
         HapticManager.trigger(.noteAdded)
         showLatestEvent(event)
 
         noteText = ""
+        noteLocation = nil
         showNoteSheet = false
     }
 
@@ -545,6 +614,7 @@ struct ActiveRecordingView: View {
     private func presentNoteSheet() {
         pendingNoteTimeMs = max(0, recordingVM.elapsedTime * 1_000)
         noteText = ""
+        noteLocation = nil
         showNoteSheet = true
     }
 
@@ -607,9 +677,10 @@ struct ActiveRecordingView: View {
                 let mergedURL = await AudioMerger.mergeAudioFiles(firstURL: existingURL, secondURL: newRecordedURL)
                 session.localAudioPath = AudioPathHelper.relativePath(from: mergedURL.path)
                 session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
-                session.isSynced = false
-                session.syncState = .pending
+                session.markContentModified()
                 try? modelContext.save()
+                scheduleAutomaticSync()
+                RecordingRecoveryStore.clear(sessionID: session.id)
                 
                 HapticManager.trigger(.recordStop)
                 isEndingRecording = false
@@ -619,15 +690,15 @@ struct ActiveRecordingView: View {
         } else {
             if existingURL != nil {
                 session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
-                session.isSynced = false
-                session.syncState = .pending
+                session.markContentModified()
                 try? modelContext.save()
             } else {
                 session.endTime = Date()
-                session.isSynced = false
-                session.syncState = .pending
+                session.markContentModified()
                 try? modelContext.save()
             }
+            scheduleAutomaticSync()
+            RecordingRecoveryStore.clear(sessionID: session.id)
             HapticManager.trigger(.recordStop)
             isEndingRecording = false
             dismiss()
@@ -676,11 +747,17 @@ struct ActiveRecordingView: View {
 
         recordName = trimmedName
         session.title = trimmedName
-        session.isSynced = false
-        session.syncState = .pending
+        session.markContentModified()
         try? modelContext.save()
     }
 
+    private func scheduleAutomaticSync() {
+        syncManager.scheduleAutomaticSync(
+            for: session,
+            modelContext: modelContext
+        )
+    }
+
     private func saveRecordNameAndCloseEditor() {
         commitRecordName()
         guard !recordName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
@@ -688,20 +765,29 @@ struct ActiveRecordingView: View {
     }
 
     private func handleRecordingURL(_ url: URL) {
-        guard url.scheme == "celestiatrace",
-              url.host == "recording" else { return }
+        guard let route = RecordingLiveActivityRoute(url: url),
+              route.sessionID == session.id else { return }
+        perform(route.action)
+    }
 
-        let components = url.pathComponents.filter { $0 != "/" }
-        guard let sessionID = components.first,
-              sessionID.caseInsensitiveCompare(session.id.uuidString) == .orderedSame else { return }
+    private func performPendingInitialActionIfReady() {
+        guard recordingVM.isRecording, let action = pendingInitialAction else { return }
+        pendingInitialAction = nil
+        perform(action)
+    }
 
-        switch components.dropFirst().first {
-        case "photo":
+    private func perform(_ action: RecordingLiveActivityAction?) {
+        switch action {
+        case .photo:
             capturePhoto()
-        case "note":
+        case .note:
             presentNoteSheet()
-        default:
-            break
+        case .togglePause:
+            toggleRecordingPause()
+        case .stop:
+            endRecording()
+        case nil:
+            return
         }
     }
 }
@@ -711,8 +797,6 @@ struct RecordNameEditorSheet: View {
     let onSave: () -> Void
     let onCancel: () -> Void
 
-    @FocusState private var isRecordNameFocused: Bool
-
     var body: some View {
         NavigationStack {
             ZStack {
@@ -724,20 +808,16 @@ struct RecordNameEditorSheet: View {
                         .foregroundStyle(Color.secondary)
                         .tracking(1.4)
 
-                    TextField("输入记录名称", text: $recordName)
-                        .focused($isRecordNameFocused)
-                        .font(.system(size: 18, weight: .semibold))
-                        .foregroundStyle(Color.primary)
-                        .textInputAutocapitalization(.never)
-                        .autocorrectionDisabled()
-                        .submitLabel(.done)
+                    SuffixSelectingTextField(
+                        placeholder: "输入记录名称",
+                        text: $recordName,
+                        selectedSuffix: "现场记录",
+                        onSubmit: onSave
+                    )
                         .padding(.horizontal, 14)
                         .padding(.vertical, 12)
                         .background(Color.cardBackground.opacity(0.5))
                         .businessBorder(cornerRadius: 8)
-                        .onSubmit {
-                            onSave()
-                        }
 
                     Spacer()
                 }
@@ -760,10 +840,84 @@ struct RecordNameEditorSheet: View {
         .presentationDetents([.height(190)])
         .presentationDragIndicator(.visible)
         .presentationBackground(Color.spaceBlack)
-        .onAppear {
-            DispatchQueue.main.async {
-                isRecordNameFocused = true
+    }
+}
+
+private struct SuffixSelectingTextField: UIViewRepresentable {
+    let placeholder: String
+    @Binding var text: String
+    let selectedSuffix: String
+    let onSubmit: () -> Void
+
+    func makeCoordinator() -> Coordinator {
+        Coordinator(text: $text, selectedSuffix: selectedSuffix, onSubmit: onSubmit)
+    }
+
+    func makeUIView(context: Context) -> UITextField {
+        let textField = UITextField()
+        textField.placeholder = placeholder
+        textField.text = text
+        textField.font = .systemFont(ofSize: 18, weight: .semibold)
+        textField.textColor = .label
+        textField.tintColor = UIColor(Color.accentColor)
+        textField.autocapitalizationType = .none
+        textField.autocorrectionType = .no
+        textField.returnKeyType = .done
+        textField.delegate = context.coordinator
+        textField.addTarget(
+            context.coordinator,
+            action: #selector(Coordinator.textDidChange(_:)),
+            for: .editingChanged
+        )
+
+        DispatchQueue.main.async {
+            textField.becomeFirstResponder()
+            context.coordinator.selectSuffixIfNeeded(in: textField)
+        }
+        return textField
+    }
+
+    func updateUIView(_ textField: UITextField, context: Context) {
+        if textField.text != text {
+            textField.text = text
+        }
+    }
+
+    final class Coordinator: NSObject, UITextFieldDelegate {
+        @Binding private var text: String
+        private let selectedSuffix: String
+        private let onSubmit: () -> Void
+        private var hasAppliedInitialSelection = false
+
+        init(text: Binding<String>, selectedSuffix: String, onSubmit: @escaping () -> Void) {
+            _text = text
+            self.selectedSuffix = selectedSuffix
+            self.onSubmit = onSubmit
+        }
+
+        @objc func textDidChange(_ textField: UITextField) {
+            text = textField.text ?? ""
+        }
+
+        func textFieldShouldReturn(_ textField: UITextField) -> Bool {
+            onSubmit()
+            return true
+        }
+
+        func selectSuffixIfNeeded(in textField: UITextField) {
+            guard !hasAppliedInitialSelection else { return }
+            hasAppliedInitialSelection = true
+
+            let fullText = textField.text ?? ""
+            guard fullText.hasSuffix(selectedSuffix),
+                  let start = textField.position(
+                    from: textField.endOfDocument,
+                    offset: -selectedSuffix.utf16.count
+                  ),
+                  let range = textField.textRange(from: start, to: textField.endOfDocument) else {
+                return
             }
+            textField.selectedTextRange = range
         }
     }
 }

+ 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)")!
     }

+ 33 - 0
Docs/RemoteAPI.openapi.yaml

@@ -295,6 +295,25 @@ paths:
               schema:
                 $ref: "#/components/schemas/ConflictEnvelope"
 
+  /sessions/{sessionId}:
+    parameters:
+      - $ref: "#/components/parameters/SessionId"
+    delete:
+      tags: [Sessions]
+      operationId: deleteSession
+      description: Soft-deletes the session and its assets for the current user.
+      responses:
+        "200":
+          description: Session deleted
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/DeleteEnvelope"
+        "401":
+          $ref: "#/components/responses/Error"
+        "404":
+          $ref: "#/components/responses/Error"
+
   /sessions/{sessionId}/assets:
     parameters:
       - $ref: "#/components/parameters/SessionId"
@@ -747,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]

+ 1 - 0
generate_project.rb

@@ -48,6 +48,7 @@ app_target.build_configurations.each do |config|
   config.build_settings['INFOPLIST_KEY_CFBundleDisplayName'] = '星痕 CelestiaTrace'
   config.build_settings['INFOPLIST_KEY_UISupportedInterfaceOrientations'] = 'UIInterfaceOrientationPortrait'
   config.build_settings['INFOPLIST_KEY_NSMicrophoneUsageDescription'] = '需要麦克风权限以进行会议现场的音轨录音'
+  config.build_settings['INFOPLIST_KEY_NSLocationWhenInUseUsageDescription'] = '需要定位权限以在笔记和照片记录中插入所在位置'
   config.build_settings['INFOPLIST_KEY_NSCameraUsageDescription'] = '需要相机权限以在现场录制中拍摄照片记录'
   config.build_settings['INFOPLIST_KEY_NSPhotoLibraryUsageDescription'] = '需要相册权限以选择照片记录'
   config.build_settings['INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription'] = '需要蓝牙权限以扫描和绑定 BLE 录音设备'

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff