2 Commity 5bf347bc7d ... e3cb739280

Autor SHA1 Wiadomość Data
  bob.yuxinyang e3cb739280 feat(recording): 优化录音笔记打点时间锚定与界面交互 1 miesiąc temu
  bob.yuxinyang ed8d2d3eb8 feat(detail): 增添回放时间轴事件查看/补加/删除与音频分享导出功能 1 miesiąc temu

+ 1 - 1
CelestiaTrace/ViewModels/PlaybackViewModel.swift

@@ -26,7 +26,7 @@ final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
     // MARK: - Silence Skip State
     
     /// Whether auto-skip silence is enabled
-    var isSilenceSkipEnabled: Bool = false
+    var isSilenceSkipEnabled: Bool = true
     
     /// Whether background silence analysis is active
     var isAnalyzingSilence: Bool = false

+ 121 - 71
CelestiaTrace/Views/Detail/MultiTrackTimeline.swift

@@ -8,11 +8,18 @@ import SwiftUI
 /// Includes a time ruler and a central playhead cursor.
 /// Fully redesigned with a minimalist business line-drawn style.
 struct MultiTrackTimeline: View {
+    enum EditableTrack {
+        case photo
+        case note
+    }
+
     let events: [CelestiaTimelineEvent]
     @Binding var currentTimeMs: Double
     let totalDurationMs: Double
     var waveformSamples: [Float] = []
     var silentRanges: [SilenceRange] = []
+    var onEmptyTrackTap: ((EditableTrack, Double) -> Void)?
+    var onEventTap: ((CelestiaTimelineEvent) -> Void)?
 
     @State private var playheadDragStartTimeMs: Double?
 
@@ -119,65 +126,69 @@ struct MultiTrackTimeline: View {
     // MARK: - Audio Track
 
     private var audioTrack: some View {
-        Canvas { context, size in
-            let centerY = size.height / 2
-            let barWidth: CGFloat = 1
-            let barSpacing: CGFloat = 2
-            let barSlot = barWidth + barSpacing
-            let startX = xPosition(for: 0)
-            let endX = xPosition(for: totalDurationMs)
-            let waveformWidth = max(0, endX - startX)
-            let barCount = max(0, Int(waveformWidth / barSlot))
-
-            for range in silentRanges {
-                let rangeStart = max(startX, xPosition(for: range.start * 1000))
-                let rangeEnd = min(endX, xPosition(for: range.end * 1000))
-                guard rangeEnd > rangeStart else { continue }
-                context.fill(
-                    Path(CGRect(
-                        x: rangeStart,
-                        y: 0,
-                        width: rangeEnd - rangeStart,
-                        height: size.height
-                    )),
-                    with: .color(Color.secondary.opacity(0.10))
+        ZStack(alignment: .leading) {
+            Canvas { context, size in
+                let centerY = size.height / 2
+                let barWidth: CGFloat = 1
+                let barSpacing: CGFloat = 2
+                let barSlot = barWidth + barSpacing
+                let startX = xPosition(for: 0)
+                let endX = xPosition(for: totalDurationMs)
+                let waveformWidth = max(0, endX - startX)
+                let barCount = max(0, Int(waveformWidth / barSlot))
+
+                for range in silentRanges {
+                    let rangeStart = max(startX, xPosition(for: range.start * 1000))
+                    let rangeEnd = min(endX, xPosition(for: range.end * 1000))
+                    guard rangeEnd > rangeStart else { continue }
+                    context.fill(
+                        Path(CGRect(
+                            x: rangeStart,
+                            y: 0,
+                            width: rangeEnd - rangeStart,
+                            height: size.height
+                        )),
+                        with: .color(Color.secondary.opacity(0.10))
+                    )
+                }
+
+                let centerLine = Path { path in
+                    path.move(to: CGPoint(x: startX, y: centerY))
+                    path.addLine(to: CGPoint(x: endX, y: centerY))
+                }
+                context.stroke(
+                    centerLine,
+                    with: .color(Color.primary.opacity(0.1)),
+                    lineWidth: 1
                 )
-            }
 
-            let centerLine = Path { path in
-                path.move(to: CGPoint(x: startX, y: centerY))
-                path.addLine(to: CGPoint(x: endX, y: centerY))
+                guard barCount > 0, !waveformSamples.isEmpty else { return }
+
+                for index in 0..<barCount {
+                    let sampleStart = index * waveformSamples.count / barCount
+                    let sampleEnd = max(
+                        sampleStart + 1,
+                        (index + 1) * waveformSamples.count / barCount
+                    )
+                    let upperBound = min(sampleEnd, waveformSamples.count)
+                    guard sampleStart < upperBound else { continue }
+
+                    let amplitude = waveformSamples[sampleStart..<upperBound].max() ?? 0
+                    let barHeight = max(CGFloat(max(amplitude, 0)) * size.height * 0.75, 1)
+                    let rect = CGRect(
+                        x: startX + CGFloat(index) * barSlot,
+                        y: centerY - barHeight / 2,
+                        width: barWidth,
+                        height: barHeight
+                    )
+                    context.fill(
+                        Path(rect),
+                        with: .color(Color.primary.opacity(0.85))
+                    )
+                }
             }
-            context.stroke(
-                centerLine,
-                with: .color(Color.primary.opacity(0.1)),
-                lineWidth: 1
-            )
-
-            guard barCount > 0, !waveformSamples.isEmpty else { return }
 
-            for index in 0..<barCount {
-                let sampleStart = index * waveformSamples.count / barCount
-                let sampleEnd = max(
-                    sampleStart + 1,
-                    (index + 1) * waveformSamples.count / barCount
-                )
-                let upperBound = min(sampleEnd, waveformSamples.count)
-                guard sampleStart < upperBound else { continue }
-
-                let amplitude = waveformSamples[sampleStart..<upperBound].max() ?? 0
-                let barHeight = max(CGFloat(max(amplitude, 0)) * size.height * 0.75, 1)
-                let rect = CGRect(
-                    x: startX + CGFloat(index) * barSlot,
-                    y: centerY - barHeight / 2,
-                    width: barWidth,
-                    height: barHeight
-                )
-                context.fill(
-                    Path(rect),
-                    with: .color(Color.primary.opacity(0.85))
-                )
-            }
+            trackLeadingIcon(systemName: "waveform", accessibilityLabel: "音频轨道")
         }
     }
 
@@ -185,16 +196,24 @@ struct MultiTrackTimeline: View {
 
     private var photoTrack: some View {
         ZStack(alignment: .leading) {
-            Color.clear
+            emptyTrackTapTarget(for: .photo)
+
+            trackLeadingIcon(systemName: "camera", accessibilityLabel: "图片轨道")
 
             ForEach(photoEvents) { event in
-                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)
-                    .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
+                Button {
+                    onEventTap?(event)
+                } 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)
+                }
+                .buttonStyle(.plain)
+                .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
+                .accessibilityLabel("查看 \(event.relativeTimeFormatted) 的图片")
             }
         }
     }
@@ -203,17 +222,25 @@ struct MultiTrackTimeline: View {
 
     private var noteTrack: some View {
         ZStack(alignment: .leading) {
-            Color.clear
+            emptyTrackTapTarget(for: .note)
+
+            trackLeadingIcon(systemName: "doc.text", accessibilityLabel: "笔记轨道")
 
             ForEach(noteEvents) { event in
                 let iconName = event.eventType == "MARKER" ? "exclamationmark.triangle" : "doc.text"
-                Image(systemName: iconName)
-                    .font(.system(size: 9))
-                    .foregroundStyle(Color.primary.opacity(0.8))
-                    .frame(width: 20, height: 20)
-                    .background(Color.cardBackground.opacity(0.6))
-                    .businessBorder(cornerRadius: 10)
-                    .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
+                Button {
+                    onEventTap?(event)
+                } label: {
+                    Image(systemName: iconName)
+                        .font(.system(size: 9))
+                        .foregroundStyle(Color.primary.opacity(0.8))
+                        .frame(width: 20, height: 20)
+                        .background(Color.cardBackground.opacity(0.6))
+                        .businessBorder(cornerRadius: 10)
+                }
+                .buttonStyle(.plain)
+                .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
+                .accessibilityLabel("查看 \(event.relativeTimeFormatted) 的笔记")
             }
         }
     }
@@ -296,6 +323,29 @@ struct MultiTrackTimeline: View {
         .foregroundStyle(Color.secondary)
     }
 
+    private func trackLeadingIcon(
+        systemName: String,
+        accessibilityLabel: String
+    ) -> some View {
+        Image(systemName: systemName)
+            .font(.system(size: 10, weight: .regular))
+            .foregroundStyle(Color.secondary.opacity(0.85))
+            .frame(width: 24)
+            .accessibilityLabel(accessibilityLabel)
+    }
+
+    private func emptyTrackTapTarget(for track: EditableTrack) -> some View {
+        Color.clear
+            .contentShape(Rectangle())
+            .gesture(
+                SpatialTapGesture()
+                    .onEnded { value in
+                        let timeMs = timeMs(forXPosition: value.location.x)
+                        onEmptyTrackTap?(track, timeMs)
+                    }
+            )
+    }
+
     // MARK: - Helpers
 
     private func xPosition(for timeMs: Double) -> CGFloat {

+ 486 - 29
CelestiaTrace/Views/Detail/SessionDetailView.swift

@@ -1,5 +1,6 @@
 import SwiftUI
 import SwiftData
+import UIKit
 
 // MARK: - SessionDetailView
 /// The session detail & playback screen.
@@ -20,6 +21,20 @@ struct SessionDetailView: View {
     @State private var activeWarningMsg = ""
     @State private var showRecordingSourcePicker = false
     @State private var selectedRecordingSource: RecordingSourceChoice = .iPhone
+    @State private var recordName = ""
+    @State private var showRecordNameEditor = false
+    @State private var showSessionInfo = false
+    @State private var showShareUnavailableAlert = false
+    @State private var audioShareItem: AudioShareItem?
+    @State private var preparedShareDirectoryURL: URL?
+    @State private var pendingTimelineTimeMs: Double = 0
+    @State private var noteText = ""
+    @State private var showNoteEditor = false
+    @State private var showPhotoActionDialog = false
+    @State private var showImagePicker = false
+    @State private var imagePickerSource: UIImagePickerController.SourceType = .photoLibrary
+    @State private var selectedTimelineEvent: CelestiaTimelineEvent?
+    @State private var timelineEditError: String?
 
     var body: some View {
         ZStack {
@@ -27,20 +42,16 @@ struct SessionDetailView: View {
 
             ScrollView {
                 VStack(spacing: 20) {
-                    // Section 1: Session Info Card
-                    sessionInfoCard
-                        .padding(.horizontal, 16)
-                        .padding(.top, 12)
-
-                    // Section 2: Multi-Track Timeline
+                    // Section 1: Multi-Track Timeline
                     timelineSection
                         .padding(.horizontal, 16)
+                        .padding(.top, 12)
 
-                    // Section 3: Playback Controls
+                    // Section 2: Playback Controls
                     playbackControls
                         .padding(.horizontal, 16)
 
-                    // Section 4: Chrono Feed
+                    // Section 3: Chrono Feed
                     chronoFeedSection
                         .padding(.horizontal, 16)
                         .padding(.bottom, 32)
@@ -50,28 +61,64 @@ struct SessionDetailView: View {
         .navigationBarTitleDisplayMode(.inline)
         .toolbar {
             ToolbarItem(placement: .principal) {
-                Text(session.title)
-                    .font(.system(size: 14, weight: .semibold))
-                    .foregroundStyle(Color.primary)
-                    .lineLimit(1)
-            }
-            ToolbarItem(placement: .topBarTrailing) {
                 Button {
-                    prepareContinuation()
+                    recordName = session.title
+                    showRecordNameEditor = true
                 } label: {
-                    HStack(spacing: 4) {
-                        Image(systemName: "plus")
-                            .font(.system(size: 11, weight: .medium))
-                        Text("续录")
-                            .font(.system(size: 12, weight: .regular))
+                    HStack(spacing: 5) {
+                        Text(session.title)
+                            .font(.system(size: 14, weight: .semibold))
+                            .lineLimit(1)
+
+                        Image(systemName: "pencil")
+                            .font(.system(size: 10, weight: .semibold))
                     }
                     .foregroundStyle(Color.primary)
-                    .padding(.horizontal, 10)
-                    .padding(.vertical, 4)
-                    .background(Color.primary.opacity(0.02))
-                    .businessBorder(cornerRadius: 6)
+                    .contentShape(Rectangle())
                 }
+                .buttonStyle(.plain)
             }
+            ToolbarItemGroup(placement: .topBarTrailing) {
+                Button {
+                    showSessionInfo = true
+                } label: {
+                    Image(systemName: "info.circle")
+                        .font(.system(size: 16, weight: .regular))
+                        .foregroundStyle(Color.primary)
+                }
+                .accessibilityLabel("记录信息")
+                .popover(isPresented: $showSessionInfo, arrowEdge: .top) {
+                    sessionInfoCard
+                        .padding(16)
+                        .frame(idealWidth: 360)
+                        .presentationCompactAdaptation(.popover)
+                }
+
+                Button {
+                    prepareAudioForSharing()
+                } label: {
+                    Image(systemName: "square.and.arrow.up")
+                        .font(.system(size: 16, weight: .regular))
+                        .foregroundStyle(Color.primary)
+                }
+                .accessibilityLabel("分享录音")
+            }
+        }
+        .sheet(item: $audioShareItem, onDismiss: cleanupSharedAudioCopy) { item in
+            AudioShareSheet(fileURL: item.fileURL)
+        }
+        .alert("无法分享录音", isPresented: $showShareUnavailableAlert) {
+            Button("知道了", role: .cancel) {}
+        } message: {
+            Text("当前记录没有可用的本地录音文件,请先完成录制或同步录音。")
+        }
+        .alert("无法完成编辑", isPresented: Binding(
+            get: { timelineEditError != nil },
+            set: { if !$0 { timelineEditError = nil } }
+        )) {
+            Button("知道了", role: .cancel) {}
+        } message: {
+            Text(timelineEditError ?? "请稍后重试。")
         }
         .alert("录音环境提醒", isPresented: $showPrepAlert) {
             Button("继续录制", role: .none) {
@@ -99,6 +146,48 @@ struct SessionDetailView: View {
                 navigateToRecording = true
             }
         }
+        .sheet(isPresented: $showRecordNameEditor) {
+            RecordNameEditorSheet(recordName: $recordName) {
+                saveRecordName()
+            } onCancel: {
+                recordName = session.title
+                showRecordNameEditor = false
+            }
+        }
+        .sheet(isPresented: $showNoteEditor) {
+            timelineNoteEditor
+        }
+        .sheet(isPresented: $showImagePicker) {
+            ImagePicker(sourceType: imagePickerSource) { image in
+                addPhoto(image, at: pendingTimelineTimeMs)
+            }
+        }
+        .sheet(item: $selectedTimelineEvent) { event in
+            TimelineEventDetailSheet(event: event) {
+                deleteTimelineEvent(event)
+            }
+        }
+        .confirmationDialog(
+            "在 \(formattedTimelineTime(pendingTimelineTimeMs)) 添加图片",
+            isPresented: $showPhotoActionDialog,
+            titleVisibility: .visible
+        ) {
+            if UIImagePickerController.isSourceTypeAvailable(.camera) {
+                Button("拍照") {
+                    imagePickerSource = .camera
+                    showImagePicker = true
+                }
+            }
+
+            Button("从相册选择") {
+                imagePickerSource = .photoLibrary
+                showImagePicker = true
+            }
+
+            Button("取消", role: .cancel) {}
+        } message: {
+            Text("图片会添加到当前时间位置。")
+        }
         .onAppear {
             playbackVM.totalDurationMs = Double(session.durationMs)
             playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
@@ -125,6 +214,69 @@ struct SessionDetailView: View {
         }
     }
 
+    private func saveRecordName() {
+        let trimmedName = recordName.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedName.isEmpty else { return }
+
+        recordName = trimmedName
+        if session.title != trimmedName {
+            session.title = trimmedName
+            session.isSynced = false
+            session.syncState = .pending
+            try? modelContext.save()
+        }
+        showRecordNameEditor = false
+    }
+
+    private func prepareAudioForSharing() {
+        guard let sourceURL = AudioPathHelper.resolveURL(for: session.localAudioPath) else {
+            showShareUnavailableAlert = true
+            return
+        }
+
+        let shareDirectory = FileManager.default.temporaryDirectory
+            .appendingPathComponent("CelestiaTraceShare", isDirectory: true)
+            .appendingPathComponent(UUID().uuidString, isDirectory: true)
+        let fileExtension = sourceURL.pathExtension
+        let fileName = sanitizedShareFileName(from: session.title)
+        let destinationURL = shareDirectory
+            .appendingPathComponent(fileName)
+            .appendingPathExtension(fileExtension)
+
+        do {
+            try FileManager.default.createDirectory(
+                at: shareDirectory,
+                withIntermediateDirectories: true
+            )
+            try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
+            preparedShareDirectoryURL = shareDirectory
+            audioShareItem = AudioShareItem(fileURL: destinationURL)
+        } catch {
+            audioShareItem = nil
+            preparedShareDirectoryURL = nil
+            showShareUnavailableAlert = true
+        }
+    }
+
+    private func sanitizedShareFileName(from title: String) -> String {
+        let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
+            .union(.controlCharacters)
+            .union(.newlines)
+        let components = title.components(separatedBy: invalidCharacters)
+        let sanitized = components
+            .joined(separator: "-")
+            .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines.union(
+                CharacterSet(charactersIn: ".")
+            ))
+        return sanitized.isEmpty ? "现场记录" : sanitized
+    }
+
+    private func cleanupSharedAudioCopy() {
+        guard let preparedShareDirectoryURL else { return }
+        try? FileManager.default.removeItem(at: preparedShareDirectoryURL)
+        self.preparedShareDirectoryURL = nil
+    }
+
     private var connectedSparkDevices: [BoundDevice] {
         guard let userID = authManager.currentUser?.id else { return [] }
         return bleManager.connectedDevices(forUserId: userID)
@@ -148,7 +300,7 @@ struct SessionDetailView: View {
                             .foregroundStyle(Color.secondary)
                     }
 
-                    Text(session.startTime.formatted(.dateTime.month().day().hour().minute()))
+                    Text(Self.chineseDateTimeFormatter.string(from: session.startTime))
                         .font(.system(size: 13, weight: .medium))
                         .foregroundStyle(Color.primary)
                 }
@@ -166,7 +318,7 @@ struct SessionDetailView: View {
                             .foregroundStyle(Color.secondary)
                     }
 
-                    Text(session.endTime?.formatted(.dateTime.month().day().hour().minute()) ?? "进行中")
+                    Text(session.endTime.map { Self.chineseDateTimeFormatter.string(from: $0) } ?? "进行中")
                         .font(.system(size: 13, weight: .medium))
                         .foregroundStyle(Color.primary)
                 }
@@ -213,6 +365,14 @@ struct SessionDetailView: View {
         .businessBorder(cornerRadius: 10)
     }
 
+    private static let chineseDateTimeFormatter: 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 infoStat(icon: String, label: String, value: String) -> some View {
         VStack(spacing: 4) {
             Image(systemName: icon)
@@ -234,7 +394,6 @@ struct SessionDetailView: View {
 
     private var timelineSection: some View {
         VStack(alignment: .leading, spacing: 8) {
-            sectionHeader(icon: "waveform", title: "三轨时间线")
 
             MultiTrackTimeline(
                 events: session.events,
@@ -244,7 +403,13 @@ struct SessionDetailView: View {
                 ),
                 totalDurationMs: playbackVM.totalDurationMs,
                 waveformSamples: playbackVM.waveformSamples,
-                silentRanges: playbackVM.silentRanges
+                silentRanges: playbackVM.silentRanges,
+                onEmptyTrackTap: handleEmptyTrackTap,
+                onEventTap: { event in
+                    playbackVM.seekTo(timeMs: Double(event.relativeTimeMs))
+                    selectedTimelineEvent = event
+                    HapticManager.trigger(.tapFeedback)
+                }
             )
             .frame(height: 140)
         }
@@ -325,7 +490,7 @@ struct SessionDetailView: View {
             
             HStack {
                 Label {
-                    Text("智能跳过静音")
+                    Text("跳过静音")
                         .font(.system(size: 12, weight: .medium))
                         .foregroundStyle(Color.primary)
                 } icon: {
@@ -387,6 +552,11 @@ struct SessionDetailView: View {
                     ForEach(sorted) { event in
                         ChronoFeedRow(event: event) {
                             playbackVM.seekTo(timeMs: event.relativeTimeMs)
+                            if event.eventType == "PHOTO"
+                                || event.eventType == "NOTE"
+                                || event.eventType == "MARKER" {
+                                selectedTimelineEvent = event
+                            }
                             HapticManager.trigger(.tapFeedback)
                         }
                     }
@@ -418,6 +588,293 @@ struct SessionDetailView: View {
         }
         return String(format: "%02d:%02d", minutes, seconds)
     }
+
+    // MARK: - Timeline Editing
+
+    private var timelineNoteEditor: some View {
+        NavigationStack {
+            ZStack {
+                Color.spaceBlack.ignoresSafeArea()
+
+                VStack(alignment: .leading, spacing: 16) {
+                    Label(
+                        "添加到 \(formattedTimelineTime(pendingTimelineTimeMs))",
+                        systemImage: "clock"
+                    )
+                    .font(.system(size: 12, weight: .medium))
+                    .foregroundStyle(Color.secondary)
+
+                    ZStack(alignment: .topLeading) {
+                        if noteText.isEmpty {
+                            Text("输入笔记内容…")
+                                .font(.system(size: 15))
+                                .foregroundStyle(Color.secondary.opacity(0.6))
+                                .padding(.horizontal, 18)
+                                .padding(.vertical, 16)
+                                .allowsHitTesting(false)
+                        }
+
+                        TextEditor(text: $noteText)
+                            .font(.system(size: 15))
+                            .foregroundStyle(Color.primary)
+                            .scrollContentBackground(.hidden)
+                            .padding(10)
+                            .frame(minHeight: 160)
+                            .background(Color.cardBackground.opacity(0.5))
+                            .businessBorder(cornerRadius: 8)
+                    }
+
+                    Spacer()
+                }
+                .padding(20)
+            }
+            .navigationTitle("添加笔记")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("取消") {
+                        noteText = ""
+                        showNoteEditor = false
+                    }
+                }
+
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("保存") {
+                        addNote(at: pendingTimelineTimeMs)
+                    }
+                    .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+                }
+            }
+        }
+        .presentationDetents([.medium])
+        .presentationDragIndicator(.visible)
+        .presentationBackground(Color.spaceBlack)
+    }
+
+    private func handleEmptyTrackTap(
+        _ track: MultiTrackTimeline.EditableTrack,
+        timeMs: Double
+    ) {
+        pendingTimelineTimeMs = min(max(timeMs, 0), playbackVM.totalDurationMs)
+        playbackVM.seekTo(timeMs: pendingTimelineTimeMs)
+        HapticManager.trigger(.tapFeedback)
+
+        switch track {
+        case .photo:
+            showPhotoActionDialog = true
+        case .note:
+            noteText = ""
+            showNoteEditor = true
+        }
+    }
+
+    private func addNote(at timeMs: Double) {
+        let trimmedText = noteText.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedText.isEmpty else { return }
+
+        let event = CelestiaTimelineEvent(
+            relativeTimeMs: Int64(timeMs.rounded()),
+            eventType: "NOTE"
+        )
+        event.textContent = trimmedText
+        session.events.append(event)
+
+        do {
+            try saveTimelineChanges()
+            noteText = ""
+            showNoteEditor = false
+        } catch {
+            modelContext.rollback()
+            timelineEditError = "笔记保存失败:\(error.localizedDescription)"
+        }
+    }
+
+    private func addPhoto(_ image: UIImage, at timeMs: Double) {
+        guard let data = image.jpegData(compressionQuality: 0.85),
+              let documentsURL = FileManager.default.urls(
+                for: .documentDirectory,
+                in: .userDomainMask
+              ).first else {
+            timelineEditError = "无法读取所选图片。"
+            return
+        }
+
+        let filename = "photo_\(UUID().uuidString).jpg"
+        let fileURL = documentsURL.appendingPathComponent(filename)
+
+        do {
+            try data.write(to: fileURL, options: .atomic)
+
+            let event = CelestiaTimelineEvent(
+                relativeTimeMs: Int64(timeMs.rounded()),
+                eventType: "PHOTO"
+            )
+            event.localFilePath = filename
+            session.events.append(event)
+
+            do {
+                try saveTimelineChanges()
+            } catch {
+                modelContext.rollback()
+                try? FileManager.default.removeItem(at: fileURL)
+                throw error
+            }
+        } catch {
+            timelineEditError = "图片保存失败:\(error.localizedDescription)"
+        }
+    }
+
+    private func deleteTimelineEvent(_ event: CelestiaTimelineEvent) {
+        let photoURL = event.eventType == "PHOTO"
+            ? AudioPathHelper.resolveURL(for: event.localFilePath)
+            : nil
+
+        modelContext.delete(event)
+
+        do {
+            try saveTimelineChanges()
+            if let photoURL {
+                try? FileManager.default.removeItem(at: photoURL)
+            }
+            selectedTimelineEvent = nil
+        } catch {
+            modelContext.rollback()
+            timelineEditError = "删除失败:\(error.localizedDescription)"
+        }
+    }
+
+    private func saveTimelineChanges() throws {
+        session.isSynced = false
+        session.syncState = .pending
+        try modelContext.save()
+    }
+
+    private func formattedTimelineTime(_ timeMs: Double) -> String {
+        let totalSeconds = max(0, Int(timeMs / 1_000))
+        let hours = totalSeconds / 3_600
+        let minutes = (totalSeconds % 3_600) / 60
+        let seconds = totalSeconds % 60
+
+        if hours > 0 {
+            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
+        }
+        return String(format: "%02d:%02d", minutes, seconds)
+    }
+}
+
+private struct TimelineEventDetailSheet: View {
+    @Environment(\.dismiss) private var dismiss
+
+    let event: CelestiaTimelineEvent
+    let onDelete: () -> Void
+
+    @State private var showDeleteConfirmation = false
+
+    var body: some View {
+        NavigationStack {
+            ZStack {
+                Color.spaceBlack.ignoresSafeArea()
+
+                ScrollView {
+                    VStack(alignment: .leading, spacing: 16) {
+                        Label(event.relativeTimeFormatted, systemImage: "clock")
+                            .font(.system(size: 12, weight: .medium, design: .monospaced))
+                            .foregroundStyle(Color.secondary)
+
+                        eventContent
+
+                        Button(role: .destructive) {
+                            showDeleteConfirmation = true
+                        } label: {
+                            Label("删除这条记录", systemImage: "trash")
+                                .font(.system(size: 14, weight: .medium))
+                                .frame(maxWidth: .infinity)
+                                .padding(.vertical, 12)
+                        }
+                        .buttonStyle(.bordered)
+                        .tint(.red)
+                    }
+                    .padding(20)
+                }
+            }
+            .navigationTitle(event.eventType == "PHOTO" ? "查看图片" : "查看笔记")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("完成") {
+                        dismiss()
+                    }
+                }
+            }
+            .confirmationDialog(
+                "确定删除这条记录吗?",
+                isPresented: $showDeleteConfirmation,
+                titleVisibility: .visible
+            ) {
+                Button("删除", role: .destructive) {
+                    onDelete()
+                    dismiss()
+                }
+                Button("取消", role: .cancel) {}
+            } message: {
+                Text(event.eventType == "PHOTO" ? "本地图片文件也会被删除,此操作无法撤销。" : "此操作无法撤销。")
+            }
+        }
+        .presentationDetents(event.eventType == "PHOTO" ? [.medium, .large] : [.medium])
+        .presentationDragIndicator(.visible)
+        .presentationBackground(Color.spaceBlack)
+    }
+
+    @ViewBuilder
+    private var eventContent: some View {
+        if event.eventType == "PHOTO" {
+            if let url = AudioPathHelper.resolveURL(for: event.localFilePath),
+               let image = UIImage(contentsOfFile: url.path) {
+                Image(uiImage: image)
+                    .resizable()
+                    .scaledToFit()
+                    .frame(maxWidth: .infinity)
+                    .clipShape(RoundedRectangle(cornerRadius: 10))
+                    .overlay {
+                        RoundedRectangle(cornerRadius: 10)
+                            .stroke(Color.lineBorder, lineWidth: 1)
+                    }
+            } else {
+                ContentUnavailableView(
+                    "图片不可用",
+                    systemImage: "photo.badge.exclamationmark",
+                    description: Text("本地图片文件可能已被移动或删除。")
+                )
+                .frame(maxWidth: .infinity, minHeight: 180)
+            }
+        } else {
+            Text(event.textContent ?? "暂无笔记内容")
+                .font(.system(size: 15))
+                .foregroundStyle(Color.primary)
+                .frame(maxWidth: .infinity, alignment: .leading)
+                .padding(16)
+                .background(Color.cardBackground.opacity(0.5))
+                .businessBorder(cornerRadius: 8)
+        }
+    }
+}
+
+private struct AudioShareItem: Identifiable {
+    let id = UUID()
+    let fileURL: URL
+}
+
+private struct AudioShareSheet: UIViewControllerRepresentable {
+    let fileURL: URL
+
+    func makeUIViewController(context: Context) -> UIActivityViewController {
+        UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
+    }
+
+    func updateUIViewController(
+        _ uiViewController: UIActivityViewController,
+        context: Context
+    ) {}
 }
 
 // MARK: - Preview

+ 9 - 4
CelestiaTrace/Views/Home/HomeView.swift

@@ -109,10 +109,15 @@ struct HomeView: View {
                 beginNewRecording()
             }
 
-            Text("开始")
-                .font(.system(size: 16, weight: .medium))
-                .foregroundStyle(Color.primary.opacity(0.88))
-                .tracking(0.4)
+            Button {
+                beginNewRecording()
+            } label: {
+                Text("开始")
+                    .font(.system(size: 16, weight: .medium))
+                    .foregroundStyle(Color.primary.opacity(0.88))
+                    .tracking(0.4)
+            }
+            .buttonStyle(.plain)
         }
     }
 

+ 122 - 81
CelestiaTrace/Views/Recording/ActiveRecordingView.swift

@@ -18,6 +18,7 @@ struct ActiveRecordingView: View {
     @State private var showNoteSheet = false
     @State private var showCamera = false
     @State private var noteText = ""
+    @State private var pendingNoteTimeMs: Double = 0
     @State private var latestEvent: CelestiaTimelineEvent?
     @State private var latestEventVisible = false
     @State private var isEndingRecording = false
@@ -25,7 +26,6 @@ struct ActiveRecordingView: View {
     @State private var recordName: String
     @State private var showRecordNameEditor = false
     @State private var hasStartedLiveActivity = false
-    @FocusState private var isRecordNameFocused: Bool
 
     init(
         session: CelestiaSession,
@@ -190,61 +190,11 @@ struct ActiveRecordingView: View {
     }
 
     private var recordNameEditorSheet: some View {
-        NavigationStack {
-            ZStack {
-                Color.spaceBlack.ignoresSafeArea()
-
-                VStack(alignment: .leading, spacing: 10) {
-                    Text("记录名称")
-                        .font(.system(size: 10, weight: .semibold, design: .monospaced))
-                        .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)
-                        .padding(.horizontal, 14)
-                        .padding(.vertical, 12)
-                        .background(Color.cardBackground.opacity(0.5))
-                        .businessBorder(cornerRadius: 8)
-                        .onSubmit {
-                            saveRecordNameAndCloseEditor()
-                        }
-
-                    Spacer()
-                }
-                .padding(20)
-            }
-            .navigationTitle("修改记录名称")
-            .navigationBarTitleDisplayMode(.inline)
-            .toolbar {
-                ToolbarItem(placement: .cancellationAction) {
-                    Button("取消") {
-                        recordName = session.title
-                        showRecordNameEditor = false
-                    }
-                    .foregroundStyle(Color.secondary)
-                }
-
-                ToolbarItem(placement: .confirmationAction) {
-                    Button("保存") {
-                        saveRecordNameAndCloseEditor()
-                    }
-                    .disabled(recordName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
-                }
-            }
-        }
-        .presentationDetents([.height(190)])
-        .presentationDragIndicator(.visible)
-        .presentationBackground(Color.spaceBlack)
-        .onAppear {
-            DispatchQueue.main.async {
-                isRecordNameFocused = true
-            }
+        RecordNameEditorSheet(recordName: $recordName) {
+            saveRecordNameAndCloseEditor()
+        } onCancel: {
+            recordName = session.title
+            showRecordNameEditor = false
         }
     }
 
@@ -385,7 +335,7 @@ struct ActiveRecordingView: View {
                 icon: "doc.text",
                 label: "文字笔记"
             ) {
-                showNoteSheet = true
+                presentNoteSheet()
             }
         }
     }
@@ -483,32 +433,37 @@ struct ActiveRecordingView: View {
             ZStack {
                 Color.spaceBlack.ignoresSafeArea()
 
-                VStack(spacing: 20) {
-                    TextField("输入笔记内容...", text: $noteText, axis: .vertical)
-                        .textFieldStyle(.plain)
-                        .font(.system(size: 15))
-                        .foregroundStyle(Color.primary)
-                        .padding(14)
-                        .frame(minHeight: 120, alignment: .top)
-                        .background(Color.cardBackground.opacity(0.5))
-                        .businessBorder(cornerRadius: 8)
+                VStack(alignment: .leading, spacing: 16) {
+                    Label(
+                        "添加到 \(formattedNoteTime(pendingNoteTimeMs))",
+                        systemImage: "clock"
+                    )
+                    .font(.system(size: 12, weight: .medium))
+                    .foregroundStyle(Color.secondary)
 
-                    Button {
-                        addNote()
-                    } label: {
-                        Text("保存笔记")
-                            .font(.system(size: 14, weight: .semibold))
-                            .foregroundStyle(Color.spaceBlack)
-                            .frame(maxWidth: .infinity)
-                            .padding(.vertical, 12)
-                            .background(Color.primary)
-                            .cornerRadius(8)
+                    ZStack(alignment: .topLeading) {
+                        if noteText.isEmpty {
+                            Text("输入笔记内容…")
+                                .font(.system(size: 15))
+                                .foregroundStyle(Color.secondary.opacity(0.6))
+                                .padding(.horizontal, 18)
+                                .padding(.vertical, 16)
+                                .allowsHitTesting(false)
+                        }
+
+                        TextEditor(text: $noteText)
+                            .font(.system(size: 15))
+                            .foregroundStyle(Color.primary)
+                            .scrollContentBackground(.hidden)
+                            .padding(10)
+                            .frame(minHeight: 160)
+                            .background(Color.cardBackground.opacity(0.5))
+                            .businessBorder(cornerRadius: 8)
                     }
-                    .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
 
                     Spacer()
                 }
-                .padding(24)
+                .padding(20)
             }
             .navigationTitle("添加笔记")
             .navigationBarTitleDisplayMode(.inline)
@@ -518,7 +473,13 @@ struct ActiveRecordingView: View {
                         showNoteSheet = false
                         noteText = ""
                     }
-                    .foregroundStyle(Color.secondary)
+                }
+
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("保存") {
+                        addNote()
+                    }
+                    .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
                 }
             }
         }
@@ -553,6 +514,7 @@ struct ActiveRecordingView: View {
         guard !text.isEmpty else { return }
 
         let event = recordingVM.addNoteEvent(to: session, text: text)
+        event.relativeTimeMs = Int64(pendingNoteTimeMs.rounded())
 
         HapticManager.trigger(.noteAdded)
         showLatestEvent(event)
@@ -561,6 +523,24 @@ struct ActiveRecordingView: View {
         showNoteSheet = false
     }
 
+    private func presentNoteSheet() {
+        pendingNoteTimeMs = max(0, recordingVM.elapsedTime * 1_000)
+        noteText = ""
+        showNoteSheet = true
+    }
+
+    private func formattedNoteTime(_ timeMs: Double) -> String {
+        let totalSeconds = max(0, Int(timeMs / 1_000))
+        let hours = totalSeconds / 3_600
+        let minutes = (totalSeconds % 3_600) / 60
+        let seconds = totalSeconds % 60
+
+        if hours > 0 {
+            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
+        }
+        return String(format: "%02d:%02d", minutes, seconds)
+    }
+
     private func toggleRecordingPause() {
         guard !isEndingRecording else { return }
         HapticManager.trigger(.tapFeedback)
@@ -671,7 +651,6 @@ struct ActiveRecordingView: View {
     private func saveRecordNameAndCloseEditor() {
         commitRecordName()
         guard !recordName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
-        isRecordNameFocused = false
         showRecordNameEditor = false
     }
 
@@ -687,13 +666,75 @@ struct ActiveRecordingView: View {
         case "photo":
             capturePhoto()
         case "note":
-            showNoteSheet = true
+            presentNoteSheet()
         default:
             break
         }
     }
 }
 
+struct RecordNameEditorSheet: View {
+    @Binding var recordName: String
+    let onSave: () -> Void
+    let onCancel: () -> Void
+
+    @FocusState private var isRecordNameFocused: Bool
+
+    var body: some View {
+        NavigationStack {
+            ZStack {
+                Color.spaceBlack.ignoresSafeArea()
+
+                VStack(alignment: .leading, spacing: 10) {
+                    Text("记录名称")
+                        .font(.system(size: 10, weight: .semibold, design: .monospaced))
+                        .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)
+                        .padding(.horizontal, 14)
+                        .padding(.vertical, 12)
+                        .background(Color.cardBackground.opacity(0.5))
+                        .businessBorder(cornerRadius: 8)
+                        .onSubmit {
+                            onSave()
+                        }
+
+                    Spacer()
+                }
+                .padding(20)
+            }
+            .navigationTitle("修改记录名称")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("取消", action: onCancel)
+                        .foregroundStyle(Color.secondary)
+                }
+
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("保存", action: onSave)
+                        .disabled(recordName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+                }
+            }
+        }
+        .presentationDetents([.height(190)])
+        .presentationDragIndicator(.visible)
+        .presentationBackground(Color.spaceBlack)
+        .onAppear {
+            DispatchQueue.main.async {
+                isRecordNameFocused = true
+            }
+        }
+    }
+}
+
 // MARK: - Amplitude Level Meter View
 
 /// A minimalist graphical VU meter bar visualizing real-time audio amplitude.