2 Commits e3cb739280 ... b6287f9615

Autore SHA1 Messaggio Data
  bob.yuxinyang b6287f9615 feat(history): 优化历史会话列表布局、侧滑删除与空状态提示 1 mese fa
  bob.yuxinyang cdb82d1ead feat(continuation): 增加续录时间节点自动落盘标记与多轨时间轴可视化 1 mese fa

+ 8 - 0
CelestiaTrace/Models/CelestiaTimelineEvent.swift

@@ -24,8 +24,16 @@ final class CelestiaTimelineEvent {
         }
         return String(format: "%02d:%02d", minutes, seconds)
     }
+
+    var isContinuationMarker: Bool {
+        eventType == "MARKER" && textContent?.hasPrefix("续录时间:") == true
+    }
     
     var eventIcon: String {
+        if isContinuationMarker {
+            return "record.circle"
+        }
+
         switch eventType {
         case "PHOTO": return "camera.fill"
         case "NOTE": return "note.text"

+ 3 - 1
CelestiaTrace/Views/ContentView.swift

@@ -18,7 +18,9 @@ struct ContentView: View {
                 }
                 .tag(Tab.home)
 
-            SessionListView()
+            SessionListView {
+                selectedTab = .home
+            }
                 .tabItem {
                     Label(Tab.history.title, systemImage: Tab.history.icon)
                 }

+ 8 - 0
CelestiaTrace/Views/Detail/ChronoFeedRow.swift

@@ -126,6 +126,10 @@ struct ChronoFeedRow: View {
     // MARK: - Computed Properties
 
     private var eventIconName: String {
+        if event.isContinuationMarker {
+            return "record.circle"
+        }
+
         switch event.eventType {
         case "PHOTO": return "camera"
         case "NOTE": return "doc.text"
@@ -136,6 +140,10 @@ struct ChronoFeedRow: View {
     }
 
     private var typeLabel: String {
+        if event.isContinuationMarker {
+            return "续录"
+        }
+
         switch event.eventType {
         case "PHOTO": return "照片"
         case "NOTE": return "笔记"

+ 41 - 5
CelestiaTrace/Views/Detail/MultiTrackTimeline.swift

@@ -27,6 +27,9 @@ struct MultiTrackTimeline: View {
     private let pointsPerSecond: CGFloat = 2.5
     /// Minimum timeline width
     private let minimumWidth: CGFloat = 400
+    /// Matches the live-recording waveform height.
+    private let audioTrackHeight: CGFloat = 90
+    private let eventTrackHeight: CGFloat = 34
 
     private var timelineWidth: CGFloat {
         max(minimumWidth, CGFloat(totalDurationMs / 1000.0) * pointsPerSecond + 60)
@@ -44,23 +47,23 @@ struct MultiTrackTimeline: View {
                         VStack(spacing: 0) {
                             // Time Ruler
                             timeRuler
-                                .frame(height: 26)
+                                .frame(height: 28)
 
                             // Track 1: Audio Waveform
                             audioTrack
-                                .frame(height: 38)
+                                .frame(height: audioTrackHeight)
 
                             trackDivider
 
                             // Track 2: Photo Markers
                             photoTrack
-                                .frame(height: 28)
+                                .frame(height: eventTrackHeight)
 
                             trackDivider
 
                             // Track 3: Note Markers
                             noteTrack
-                                .frame(height: 28)
+                                .frame(height: eventTrackHeight)
                         }
 
                         // Playhead Cursor
@@ -189,6 +192,29 @@ struct MultiTrackTimeline: View {
             }
 
             trackLeadingIcon(systemName: "waveform", accessibilityLabel: "音频轨道")
+
+            ForEach(continuationEvents) { event in
+                Button {
+                    onEventTap?(event)
+                } label: {
+                    ZStack {
+                        Rectangle()
+                            .fill(Color.primary.opacity(0.35))
+                            .frame(width: 1, height: audioTrackHeight)
+
+                        Image(systemName: "record.circle")
+                            .font(.system(size: 11, weight: .medium))
+                            .foregroundStyle(Color.primary)
+                            .frame(width: 20, height: 20)
+                            .background(Color.cardBackground.opacity(0.9))
+                            .businessBorder(cornerRadius: 10)
+                    }
+                    .frame(width: 20, height: audioTrackHeight)
+                }
+                .buttonStyle(.plain)
+                .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
+                .accessibilityLabel("查看 \(event.relativeTimeFormatted) 的续录信息")
+            }
         }
     }
 
@@ -310,6 +336,9 @@ struct MultiTrackTimeline: View {
             trackLabel(icon: "waveform", text: "音频")
             trackLabel(icon: "camera", text: "图片")
             trackLabel(icon: "doc.text", text: "笔记")
+            if !continuationEvents.isEmpty {
+                trackLabel(icon: "record.circle", text: "续录")
+            }
         }
     }
 
@@ -375,7 +404,14 @@ struct MultiTrackTimeline: View {
     }
 
     private var noteEvents: [CelestiaTimelineEvent] {
-        events.filter { $0.eventType == "NOTE" || $0.eventType == "MARKER" }
+        events.filter {
+            $0.eventType == "NOTE"
+                || ($0.eventType == "MARKER" && !$0.isContinuationMarker)
+        }
+    }
+
+    private var continuationEvents: [CelestiaTimelineEvent] {
+        events.filter(\.isContinuationMarker)
     }
 }
 

+ 35 - 2
CelestiaTrace/Views/Detail/SessionDetailView.swift

@@ -196,6 +196,7 @@ struct SessionDetailView: View {
     }
 
     private func prepareContinuation() {
+        playbackVM.pause()
         detector.checkEnvironment()
         if let firstWarning = detector.activeWarnings.first {
             activeWarningMsg = firstWarning
@@ -359,6 +360,31 @@ struct SessionDetailView: View {
                 }
                 .frame(maxWidth: .infinity)
             }
+
+            Divider()
+                .background(Color.lineBorder)
+
+            Button {
+                showSessionInfo = false
+                DispatchQueue.main.async {
+                    prepareContinuation()
+                }
+            } label: {
+                HStack(spacing: 8) {
+                    Image(systemName: "record.circle")
+                        .font(.system(size: 14, weight: .medium))
+
+                    Text("续录")
+                        .font(.system(size: 13, weight: .semibold))
+                }
+                .foregroundStyle(Color.primary)
+                .frame(maxWidth: .infinity)
+                .padding(.vertical, 10)
+                .background(Color.cardBackground.opacity(0.2))
+                .businessBorder(cornerRadius: 8)
+            }
+            .buttonStyle(.plain)
+            .accessibilityLabel("继续现场记录")
         }
         .padding(14)
         .background(Color.cardBackground.opacity(0.15))
@@ -411,7 +437,7 @@ struct SessionDetailView: View {
                     HapticManager.trigger(.tapFeedback)
                 }
             )
-            .frame(height: 140)
+            .frame(height: 220)
         }
     }
 
@@ -797,7 +823,7 @@ private struct TimelineEventDetailSheet: View {
                     .padding(20)
                 }
             }
-            .navigationTitle(event.eventType == "PHOTO" ? "查看图片" : "查看笔记")
+            .navigationTitle(detailTitle)
             .navigationBarTitleDisplayMode(.inline)
             .toolbar {
                 ToolbarItem(placement: .confirmationAction) {
@@ -857,6 +883,13 @@ private struct TimelineEventDetailSheet: View {
                 .businessBorder(cornerRadius: 8)
         }
     }
+
+    private var detailTitle: String {
+        if event.isContinuationMarker {
+            return "续录详情"
+        }
+        return event.eventType == "PHOTO" ? "查看图片" : "查看笔记"
+    }
 }
 
 private struct AudioShareItem: Identifiable {

+ 125 - 79
CelestiaTrace/Views/History/SessionListView.swift

@@ -9,9 +9,15 @@ struct SessionListView: View {
     @Environment(\.modelContext) private var modelContext
     @Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession]
 
+    var onStartRecording: () -> Void = {}
+
     @State private var searchText = ""
     @State private var showSyncAuthPrompt = false
     @State private var showAuthModal = false
+    @State private var deletionError: String?
+    @State private var sessionPendingDeletion: CelestiaSession?
+    @State private var selectedSession: CelestiaSession?
+    @State private var showSessionDetail = false
     @ObservedObject private var authManager: AuthManager = .shared
     @ObservedObject private var syncManager: SyncManager = .shared
 
@@ -26,35 +32,33 @@ struct SessionListView: View {
                     sessionList
                 }
             }
-            .navigationTitle("会话档案")
+            .navigationTitle("历史记录")
             .navigationBarTitleDisplayMode(.inline)
             .toolbar {
-                ToolbarItem(placement: .topBarTrailing) {
-                    Button {
-                        guard let userID = authManager.currentUser?.id else {
-                            showSyncAuthPrompt = true
-                            return
-                        }
-                        Task {
-                            _ = await syncManager.sync(sessions: sessions, modelContext: modelContext, userID: userID)
+                if !sessions.isEmpty {
+                    ToolbarItem(placement: .topBarTrailing) {
+                        Button {
+                            guard let userID = authManager.currentUser?.id else {
+                                showSyncAuthPrompt = true
+                                return
+                            }
+                            Task {
+                                _ = await syncManager.sync(sessions: sessions, modelContext: modelContext, userID: userID)
+                            }
+                        } label: {
+                            HStack(spacing: 4) {
+                                Image(systemName: "icloud.and.arrow.up")
+                                    .font(.system(size: 13))
+                                Text(syncManager.isSyncing ? "同步中" : "同步")
+                                    .font(.system(size: 13, weight: .medium))
+                            }
+                            .foregroundStyle(Color.primary)
                         }
-                    } label: {
-                        HStack(spacing: 4) {
-                            Image(systemName: "icloud.and.arrow.up")
-                                .font(.system(size: 13))
-                            Text(syncManager.isSyncing ? "同步中" : "同步")
-                                .font(.system(size: 13, weight: .medium))
-                        }
-                        .foregroundStyle(Color.primary)
+                        .disabled(syncManager.isSyncing)
                     }
-                    .disabled(syncManager.isSyncing)
                 }
             }
-            .searchable(
-                text: $searchText,
-                placement: .navigationBarDrawer(displayMode: .always),
-                prompt: "搜索会话或笔记..."
-            )
+            .modifier(SessionSearchModifier(isEnabled: !sessions.isEmpty, searchText: $searchText))
             .sheet(isPresented: $showSyncAuthPrompt) {
                 SyncAuthPromptModal(onLoginClick: {
                     showAuthModal = true
@@ -63,6 +67,35 @@ struct SessionListView: View {
             .sheet(isPresented: $showAuthModal) {
                 AuthModalView()
             }
+            .navigationDestination(isPresented: $showSessionDetail) {
+                if let selectedSession {
+                    SessionDetailView(session: selectedSession)
+                }
+            }
+            .alert("删除失败", isPresented: Binding(
+                get: { deletionError != nil },
+                set: { if !$0 { deletionError = nil } }
+            )) {
+                Button("好", role: .cancel) {}
+            } message: {
+                Text(deletionError ?? "请稍后重试。")
+            }
+            .alert(
+                "确认删除?",
+                isPresented: Binding(
+                    get: { sessionPendingDeletion != nil },
+                    set: { if !$0 { sessionPendingDeletion = nil } }
+                ),
+                presenting: sessionPendingDeletion
+            ) { session in
+                Button("取消", role: .cancel) {}
+                Button("删除", role: .destructive) {
+                    deleteSession(session)
+                    sessionPendingDeletion = nil
+                }
+            } message: { session in
+                Text("删除「\(session.title)」后,相关录音和图片也会被删除,且无法恢复。")
+            }
         }
     }
 
@@ -82,72 +115,70 @@ struct SessionListView: View {
     // MARK: - Session List
 
     private var sessionList: some View {
-        ScrollView {
-            LazyVStack(spacing: 12) {
-                // Stats header
-                statsHeader
-                    .padding(.horizontal, 16)
-                    .padding(.top, 12)
-
-                ForEach(filteredSessions) { session in
-                    NavigationLink(destination: SessionDetailView(session: session)) {
-                        SessionRowCard(session: session)
+        List {
+            ForEach(filteredSessions) { session in
+                Button {
+                    selectedSession = session
+                    showSessionDetail = true
+                } label: {
+                    SessionRowCard(session: session)
+                        .contentShape(Rectangle())
+                }
+                .buttonStyle(.plain)
+                .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
+                .listRowBackground(Color.clear)
+                .listRowSeparator(.hidden)
+                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                    Button {
+                        sessionPendingDeletion = session
+                    } label: {
+                        Label("删除", systemImage: "trash")
                     }
-                    .buttonStyle(.plain)
-                    .padding(.horizontal, 16)
+                    .tint(.red)
                 }
             }
-            .padding(.bottom, 24)
         }
+        .listStyle(.plain)
+        .scrollContentBackground(.hidden)
+        .contentMargins(.bottom, 18, for: .scrollContent)
     }
 
-    // MARK: - Stats Header
+    private func deleteSession(_ session: CelestiaSession) {
+        let storedPaths =
+            [session.localAudioPath].compactMap { $0 } +
+            session.audioChunks.map(\.localFilePath) +
+            session.events.compactMap(\.localFilePath)
+        let fileURLs = Set(storedPaths.compactMap { AudioPathHelper.resolveURL(for: $0) })
 
-    private var statsHeader: some View {
-        HStack(spacing: 20) {
-            statBadge(
-                value: "\(filteredSessions.count)",
-                label: "总会话",
-                icon: "waveform"
-            )
+        modelContext.delete(session)
 
-            statBadge(
-                value: "\(filteredSessions.reduce(0) { $0 + $1.photoCount })",
-                label: "已获图片",
-                icon: "camera"
-            )
-
-            statBadge(
-                value: "\(filteredSessions.filter { $0.isSynced }.count)",
-                label: "已同步",
-                icon: "icloud"
-            )
+        do {
+            try modelContext.save()
+            for fileURL in fileURLs {
+                try? FileManager.default.removeItem(at: fileURL)
+            }
+        } catch {
+            modelContext.rollback()
+            deletionError = error.localizedDescription
         }
-        .padding(.vertical, 14)
-        .background(Color.cardBackground.opacity(0.3))
-        .businessBorder(cornerRadius: 10)
     }
 
-    private func statBadge(value: String, label: String, icon: String) -> some View {
-        VStack(spacing: 4) {
-            Image(systemName: icon)
-                .font(.system(size: 13, weight: .light))
-                .foregroundStyle(Color.secondary)
-
-            Text(value)
-                .font(.system(size: 16, weight: .medium, design: .monospaced))
-                .foregroundStyle(Color.primary)
+    // MARK: - Empty State
 
-            Text(label)
-                .font(.system(size: 10, weight: .regular))
-                .foregroundStyle(Color.secondary.opacity(0.8))
+    @ViewBuilder
+    private var emptyState: some View {
+        if sessions.isEmpty {
+            Button(action: onStartRecording) {
+                emptyStateContent
+                    .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+        } else {
+            emptyStateContent
         }
-        .frame(maxWidth: .infinity)
     }
 
-    // MARK: - Empty State
-
-    private var emptyState: some View {
+    private var emptyStateContent: some View {
         VStack(spacing: 16) {
             Image(systemName: "square.dashed")
                 .font(.system(size: 36))
@@ -162,15 +193,34 @@ struct SessionListView: View {
                     .font(.system(size: 12))
                     .foregroundStyle(Color.secondary)
             } else {
-                Text("启动全局录音来保存你的第一条记录")
+                Text("先试试去开始一次现场记录")
                     .font(.system(size: 12))
                     .foregroundStyle(Color.secondary.opacity(0.7))
             }
         }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
         .padding(.bottom, 60)
     }
 }
 
+private struct SessionSearchModifier: ViewModifier {
+    let isEnabled: Bool
+    @Binding var searchText: String
+
+    @ViewBuilder
+    func body(content: Content) -> some View {
+        if isEnabled {
+            content.searchable(
+                text: $searchText,
+                placement: .navigationBarDrawer(displayMode: .always),
+                prompt: "搜索会话或笔记..."
+            )
+        } else {
+            content
+        }
+    }
+}
+
 // MARK: - Session Row Card
 
 private struct SessionRowCard: View {
@@ -212,10 +262,6 @@ private struct SessionRowCard: View {
             }
             .padding(.trailing, 4)
 
-            // Chevron
-            Image(systemName: "chevron.right")
-                .font(.system(size: 10, weight: .light))
-                .foregroundStyle(Color.secondary.opacity(0.5))
         }
         .padding(14)
         .background(Color.cardBackground.opacity(0.2))

+ 27 - 0
CelestiaTrace/Views/Recording/ActiveRecordingView.swift

@@ -26,6 +26,7 @@ struct ActiveRecordingView: View {
     @State private var recordName: String
     @State private var showRecordNameEditor = false
     @State private var hasStartedLiveActivity = false
+    @State private var hasAddedContinuationMarker = false
 
     init(
         session: CelestiaSession,
@@ -109,6 +110,9 @@ struct ActiveRecordingView: View {
             recordingVM.startRecording(initialDuration: initialDuration)
         }
         .onChange(of: recordingVM.isRecording) { _, isRecording in
+            if isRecording {
+                addContinuationMarkerIfNeeded()
+            }
             guard isRecording, !hasStartedLiveActivity else { return }
             hasStartedLiveActivity = true
             RecordingLiveActivityManager.shared.start(
@@ -523,6 +527,21 @@ struct ActiveRecordingView: View {
         showNoteSheet = false
     }
 
+    private func addContinuationMarkerIfNeeded() {
+        guard initialDuration > 0, !hasAddedContinuationMarker else { return }
+        hasAddedContinuationMarker = true
+
+        let event = CelestiaTimelineEvent(
+            relativeTimeMs: Int64((initialDuration * 1_000).rounded()),
+            eventType: "MARKER"
+        )
+        event.textContent = "续录时间:\(Self.continuationDateFormatter.string(from: Date()))"
+        session.events.append(event)
+        session.isSynced = false
+        session.syncState = .pending
+        try? modelContext.save()
+    }
+
     private func presentNoteSheet() {
         pendingNoteTimeMs = max(0, recordingVM.elapsedTime * 1_000)
         noteText = ""
@@ -541,6 +560,14 @@ struct ActiveRecordingView: View {
         return String(format: "%02d:%02d", minutes, seconds)
     }
 
+    private static let continuationDateFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.locale = Locale(identifier: "zh_CN")
+        formatter.calendar = Calendar(identifier: .gregorian)
+        formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
+        return formatter
+    }()
+
     private func toggleRecordingPause() {
         guard !isEndingRecording else { return }
         HapticManager.trigger(.tapFeedback)