Преглед изворни кода

feat(location/detail): 增加事件地理位置打点、全屏双指缩放图片预览与事件内容编辑

bob.yuxinyang пре 1 месец
родитељ
комит
8821a413ce

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

+ 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) 张照片无法读取,其余照片已添加。"
+        }
+    }
+}

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

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

+ 744 - 82
CelestiaTrace/Views/Detail/SessionDetailView.swift

@@ -31,11 +31,11 @@ struct SessionDetailView: View {
     @State private var preparedShareDirectoryURL: URL?
     @State private var pendingTimelineTimeMs: Double = 0
     @State private var noteText = ""
+    @State private var noteLocation: TimelineLocation?
     @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 showPhotoEditor = false
     @State private var selectedTimelineEvent: CelestiaTimelineEvent?
+    @State private var selectedPhotoPoint: PhotoPointSelection?
     @State private var timelineEditError: String?
     @State private var showSyncAuthPrompt = false
     @State private var showAuthModal = false
@@ -150,15 +150,38 @@ struct SessionDetailView: View {
         .sheet(isPresented: $showNoteEditor) {
             timelineNoteEditor
         }
-        .sheet(isPresented: $showImagePicker) {
-            ImagePicker(sourceType: imagePickerSource) { image in
-                addPhoto(image, at: pendingTimelineTimeMs)
+        .sheet(isPresented: $showPhotoEditor) {
+            PhotoRecordEditorSheet(
+                timeLabel: formattedTimelineTime(pendingTimelineTimeMs)
+            ) { drafts, location in
+                addPhotos(
+                    drafts,
+                    location: location,
+                    at: pendingTimelineTimeMs
+                )
             }
         }
         .sheet(item: $selectedTimelineEvent) { event in
-            TimelineEventDetailSheet(event: event) {
-                deleteTimelineEvent(event)
-            }
+            TimelineEventDetailSheet(
+                event: event,
+                onSaveNote: { updatedText, updatedLocation in
+                    updateTimelineNote(
+                        event,
+                        text: updatedText,
+                        location: updatedLocation
+                    )
+                },
+                onDelete: {
+                    deleteTimelineEvent(event)
+                }
+            )
+        }
+        .sheet(item: $selectedPhotoPoint) { selection in
+            PhotoPointDetailSheet(
+                events: photoEvents(at: selection.relativeTimeMs),
+                onSaveEdits: updatePhotoEdits,
+                onDelete: deleteTimelineEvent
+            )
         }
         .sheet(isPresented: $showSyncAuthPrompt) {
             SyncAuthPromptModal {
@@ -178,27 +201,6 @@ struct SessionDetailView: View {
         } message: {
             Text(largeTransferMessage)
         }
-        .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))
@@ -557,7 +559,7 @@ struct SessionDetailView: View {
                 label: "同步时间",
                 value: session.lastSyncedAt.map {
                     Self.syncDateTimeFormatter.string(from: $0)
-                } ?? "历史记录未保存"
+                } ?? "现场记录未保存"
             )
 
             syncInfoRow(
@@ -723,7 +725,7 @@ struct SessionDetailView: View {
                 onEmptyTrackTap: handleEmptyTrackTap,
                 onEventTap: { event in
                     playbackVM.seekTo(timeMs: Double(event.relativeTimeMs))
-                    selectedTimelineEvent = event
+                    openTimelineEvent(event)
                     HapticManager.trigger(.tapFeedback)
                 }
             )
@@ -847,7 +849,7 @@ struct SessionDetailView: View {
         VStack(alignment: .leading, spacing: 10) {
             sectionHeader(icon: "list.dash", title: "事件")
 
-            let sorted = playbackVM.sortedEvents(from: session)
+            let sorted = chronoFeedItems
 
             if sorted.isEmpty {
                 HStack {
@@ -865,14 +867,13 @@ struct SessionDetailView: View {
                 }
             } else {
                 LazyVStack(spacing: 8) {
-                    ForEach(sorted) { event in
-                        ChronoFeedRow(event: event) {
-                            playbackVM.seekTo(timeMs: event.relativeTimeMs)
-                            if event.eventType == "PHOTO"
-                                || event.eventType == "NOTE"
-                                || event.eventType == "MARKER" {
-                                selectedTimelineEvent = event
-                            }
+                    ForEach(sorted) { item in
+                        ChronoFeedRow(
+                            event: item.event,
+                            photoEvents: item.photoEvents
+                        ) {
+                            playbackVM.seekTo(timeMs: item.event.relativeTimeMs)
+                            openTimelineEvent(item.event)
                             HapticManager.trigger(.tapFeedback)
                         }
                     }
@@ -922,7 +923,7 @@ struct SessionDetailView: View {
 
                     ZStack(alignment: .topLeading) {
                         if noteText.isEmpty {
-                            Text("输入笔记内容…")
+                            Text("这一刻的想法")
                                 .font(.system(size: 15))
                                 .foregroundStyle(Color.secondary.opacity(0.6))
                                 .padding(.horizontal, 18)
@@ -940,6 +941,8 @@ struct SessionDetailView: View {
                             .businessBorder(cornerRadius: 8)
                     }
 
+                    TimelineLocationButton(location: $noteLocation)
+
                     Spacer()
                 }
                 .padding(20)
@@ -950,6 +953,7 @@ struct SessionDetailView: View {
                 ToolbarItem(placement: .cancellationAction) {
                     Button("取消") {
                         noteText = ""
+                        noteLocation = nil
                         showNoteEditor = false
                     }
                 }
@@ -977,9 +981,10 @@ struct SessionDetailView: View {
 
         switch track {
         case .photo:
-            showPhotoActionDialog = true
+            showPhotoEditor = true
         case .note:
             noteText = ""
+            noteLocation = nil
             showNoteEditor = true
         }
     }
@@ -993,11 +998,13 @@ struct SessionDetailView: View {
             eventType: "NOTE"
         )
         event.textContent = trimmedText
+        event.location = noteLocation
         session.events.append(event)
 
         do {
             try saveTimelineChanges()
             noteText = ""
+            noteLocation = nil
             showNoteEditor = false
         } catch {
             modelContext.rollback()
@@ -1005,38 +1012,135 @@ struct SessionDetailView: View {
         }
     }
 
-    private func addPhoto(_ image: UIImage, at timeMs: Double) {
-        guard let data = image.jpegData(compressionQuality: 0.85),
+    private func addPhotos(
+        _ drafts: [PhotoRecordDraft],
+        location: TimelineLocation?,
+        at timeMs: Double
+    ) -> Bool {
+        guard !drafts.isEmpty,
               let documentsURL = FileManager.default.urls(
                 for: .documentDirectory,
                 in: .userDomainMask
               ).first else {
-            timelineEditError = "无法读取所选图片。"
-            return
+            timelineEditError = "无法访问本地照片目录。"
+            return false
         }
 
-        let filename = "photo_\(UUID().uuidString).jpg"
-        let fileURL = documentsURL.appendingPathComponent(filename)
+        var storedPhotos: [
+            (
+                filename: String,
+                fileURL: URL,
+                note: String
+            )
+        ] = []
 
         do {
-            try data.write(to: fileURL, options: .atomic)
+            for draft in drafts {
+                guard let data = draft.image.jpegData(compressionQuality: 0.85) else {
+                    throw CocoaError(.fileWriteUnknown)
+                }
 
-            let event = CelestiaTimelineEvent(
-                relativeTimeMs: Int64(timeMs.rounded()),
-                eventType: "PHOTO"
-            )
-            event.localFilePath = filename
-            session.events.append(event)
+                let filename = "photo_\(UUID().uuidString).jpg"
+                let fileURL = documentsURL.appendingPathComponent(filename)
+                try data.write(to: fileURL, options: .atomic)
+                storedPhotos.append(
+                    (filename, fileURL, draft.note)
+                )
+            }
+
+            let relativeTimeMs = Int64(timeMs.rounded())
+            for storedPhoto in storedPhotos {
+                let event = CelestiaTimelineEvent(
+                    relativeTimeMs: relativeTimeMs,
+                    eventType: "PHOTO"
+                )
+                event.localFilePath = storedPhoto.filename
+                let trimmedNote = storedPhoto.note.trimmingCharacters(in: .whitespacesAndNewlines)
+                event.textContent = trimmedNote.isEmpty ? nil : trimmedNote
+                event.location = location
+                session.events.append(event)
+            }
 
             do {
                 try saveTimelineChanges()
+                return true
             } catch {
                 modelContext.rollback()
-                try? FileManager.default.removeItem(at: fileURL)
+                for storedPhoto in storedPhotos {
+                    try? FileManager.default.removeItem(at: storedPhoto.fileURL)
+                }
                 throw error
             }
         } catch {
-            timelineEditError = "图片保存失败:\(error.localizedDescription)"
+            for storedPhoto in storedPhotos {
+                try? FileManager.default.removeItem(at: storedPhoto.fileURL)
+            }
+            timelineEditError = "照片保存失败:\(error.localizedDescription)"
+            return false
+        }
+    }
+
+    private func updatePhotoEdits(
+        _ notes: [UUID: String],
+        location: TimelineLocation?
+    ) -> Bool {
+        let photoEventsByID = Dictionary(
+            uniqueKeysWithValues: session.events
+                .filter { $0.eventType == "PHOTO" }
+                .map { ($0.id, $0) }
+        )
+        let previousValues = notes.reduce(
+            into: [UUID: TimelineEventEditDraft]()
+        ) { result, item in
+            guard let event = photoEventsByID[item.key] else { return }
+            result[item.key] = TimelineEventEditDraft(
+                text: event.textContent ?? "",
+                location: event.location
+            )
+            let trimmedNote = item.value
+                .trimmingCharacters(in: .whitespacesAndNewlines)
+            event.textContent = trimmedNote.isEmpty ? nil : trimmedNote
+            event.location = location
+        }
+
+        do {
+            try saveTimelineChanges()
+            return true
+        } catch {
+            for (eventID, previousValue) in previousValues {
+                photoEventsByID[eventID]?.textContent = previousValue.text.isEmpty
+                    ? nil
+                    : previousValue.text
+                photoEventsByID[eventID]?.location = previousValue.location
+            }
+            modelContext.rollback()
+            timelineEditError = "照片信息保存失败:\(error.localizedDescription)"
+            return false
+        }
+    }
+
+    private func updateTimelineNote(
+        _ event: CelestiaTimelineEvent,
+        text: String,
+        location: TimelineLocation?
+    ) -> Bool {
+        let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedText.isEmpty else { return false }
+
+        let previousText = event.textContent
+        let previousLocation = event.location
+        event.textContent = trimmedText
+        event.location = location
+
+        do {
+            try saveTimelineChanges()
+            return true
+        } catch {
+            event.textContent = previousText
+            event.location = previousLocation
+            modelContext.rollback()
+            timelineEditError = "笔记保存失败:\(error.localizedDescription)"
+            return false
         }
     }
 
@@ -1053,6 +1157,7 @@ struct SessionDetailView: View {
                 try? FileManager.default.removeItem(at: photoURL)
             }
             selectedTimelineEvent = nil
+            selectedPhotoPoint = nil
         } catch {
             modelContext.rollback()
             timelineEditError = "删除失败:\(error.localizedDescription)"
@@ -1076,51 +1181,382 @@ struct SessionDetailView: View {
         }
         return String(format: "%02d:%02d", minutes, seconds)
     }
+
+    private func openTimelineEvent(_ event: CelestiaTimelineEvent) {
+        if event.eventType == "PHOTO" {
+            selectedPhotoPoint = PhotoPointSelection(
+                eventID: event.id,
+                relativeTimeMs: event.relativeTimeMs
+            )
+        } else if event.eventType == "NOTE" || event.eventType == "MARKER" {
+            selectedTimelineEvent = event
+        }
+    }
+
+    private func photoEvents(at relativeTimeMs: Int64) -> [CelestiaTimelineEvent] {
+        session.events
+            .filter {
+                $0.eventType == "PHOTO"
+                    && $0.relativeTimeMs == relativeTimeMs
+            }
+            .sorted {
+                if $0.createdAt == $1.createdAt {
+                    return $0.id.uuidString < $1.id.uuidString
+                }
+                return $0.createdAt < $1.createdAt
+            }
+    }
+
+    private var chronoFeedItems: [ChronoFeedItem] {
+        let sortedEvents = playbackVM.sortedEvents(from: session)
+        var includedPhotoTimes = Set<Int64>()
+
+        return sortedEvents.compactMap { event in
+            guard event.eventType == "PHOTO" else {
+                return ChronoFeedItem(event: event, photoEvents: [])
+            }
+
+            guard includedPhotoTimes.insert(event.relativeTimeMs).inserted else {
+                return nil
+            }
+
+            return ChronoFeedItem(
+                event: event,
+                photoEvents: photoEvents(at: event.relativeTimeMs)
+            )
+        }
+    }
+}
+
+private struct PhotoPointSelection: Identifiable {
+    let eventID: UUID
+    let relativeTimeMs: Int64
+
+    var id: UUID { eventID }
+}
+
+private struct ChronoFeedItem: Identifiable {
+    let event: CelestiaTimelineEvent
+    let photoEvents: [CelestiaTimelineEvent]
+
+    var id: UUID { event.id }
+}
+
+private struct TimelineImagePresentation: Identifiable {
+    let id = UUID()
+    let image: UIImage
+    let capturedAt: Date
+    let note: String
+}
+
+private struct TimelineEventEditDraft: Equatable {
+    var text: String
+    var location: TimelineLocation?
+}
+
+private struct PhotoPointDetailSheet: View {
+    @Environment(\.dismiss) private var dismiss
+
+    let events: [CelestiaTimelineEvent]
+    let onSaveEdits: ([UUID: String], TimelineLocation?) -> Bool
+    let onDelete: (CelestiaTimelineEvent) -> Void
+
+    @State private var noteDrafts: [UUID: String]
+    @State private var locationDraft: TimelineLocation?
+    @State private var pendingDeletionEvent: CelestiaTimelineEvent?
+    @State private var fullScreenImage: TimelineImagePresentation?
+
+    init(
+        events: [CelestiaTimelineEvent],
+        onSaveEdits: @escaping ([UUID: String], TimelineLocation?) -> Bool,
+        onDelete: @escaping (CelestiaTimelineEvent) -> Void
+    ) {
+        self.events = events
+        self.onSaveEdits = onSaveEdits
+        self.onDelete = onDelete
+        _noteDrafts = State(
+            initialValue: Dictionary(
+                uniqueKeysWithValues: events.map {
+                    ($0.id, $0.textContent ?? "")
+                }
+            )
+        )
+        _locationDraft = State(
+            initialValue: events.compactMap(\.location).first
+        )
+    }
+
+    var body: some View {
+        NavigationStack {
+            ZStack {
+                Color.spaceBlack.ignoresSafeArea()
+
+                if events.isEmpty {
+                    ContentUnavailableView(
+                        "照片不可用",
+                        systemImage: "photo.badge.exclamationmark",
+                        description: Text("这个记录点没有可查看的本地照片。")
+                    )
+                } else {
+                    ScrollView {
+                        VStack(alignment: .leading, spacing: 16) {
+                            HStack {
+                                Label(
+                                    events[0].relativeTimeFormatted,
+                                    systemImage: "clock"
+                                )
+                                .font(.system(size: 12, weight: .medium, design: .monospaced))
+                                .foregroundStyle(Color.secondary)
+
+                                Spacer()
+
+                                Text("\(events.count) 张照片")
+                                    .font(.system(size: 12, design: .monospaced))
+                                    .foregroundStyle(Color.secondary)
+                            }
+
+                            TimelineLocationButton(location: $locationDraft)
+
+                            LazyVStack(spacing: 28) {
+                                ForEach(Array(events.enumerated()), id: \.element.id) { index, event in
+                                    photoListItem(event, index: index)
+                                }
+                            }
+                        }
+                        .padding(20)
+                    }
+                }
+            }
+            .navigationTitle(events.count > 1 ? "查看照片(\(events.count) 张)" : "查看照片")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("完成") {
+                        dismiss()
+                    }
+                }
+
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("保存") {
+                        if onSaveEdits(noteDrafts, locationDraft) {
+                            dismiss()
+                        }
+                    }
+                    .disabled(!hasChanges)
+                }
+            }
+            .confirmationDialog(
+                "确定删除这张照片吗?",
+                isPresented: Binding(
+                    get: { pendingDeletionEvent != nil },
+                    set: { if !$0 { pendingDeletionEvent = nil } }
+                ),
+                titleVisibility: .visible
+            ) {
+                Button("删除", role: .destructive) {
+                    guard let event = pendingDeletionEvent else { return }
+                    onDelete(event)
+                    dismiss()
+                }
+                Button("取消", role: .cancel) {}
+            } message: {
+                Text("本地图片文件和这张照片的备注都会被删除,此操作无法撤销。")
+            }
+        }
+        .fullScreenCover(item: $fullScreenImage) { item in
+            FullScreenTimelineImageView(
+                image: item.image,
+                capturedAt: item.capturedAt,
+                note: item.note
+            )
+        }
+        .presentationDetents([.large])
+        .presentationDragIndicator(.visible)
+        .presentationBackground(Color.spaceBlack)
+    }
+
+    private func photoListItem(
+        _ event: CelestiaTimelineEvent,
+        index: Int
+    ) -> some View {
+        VStack(alignment: .leading, spacing: 10) {
+            HStack {
+                Text("照片 \(index + 1)")
+                    .font(.system(size: 11, weight: .medium))
+                    .foregroundStyle(Color.secondary)
+
+                Spacer()
+
+                Button(role: .destructive) {
+                    pendingDeletionEvent = event
+                } label: {
+                    Image(systemName: "trash")
+                        .font(.system(size: 11, weight: .medium))
+                        .frame(width: 24, height: 24)
+                }
+                .buttonStyle(.plain)
+                .foregroundStyle(Color.red)
+                .accessibilityLabel("删除照片 \(index + 1)")
+            }
+
+            if let image = resolvedImage(for: event) {
+                Button {
+                    fullScreenImage = TimelineImagePresentation(
+                        image: image,
+                        capturedAt: event.createdAt,
+                        note: noteDrafts[event.id] ?? ""
+                    )
+                } label: {
+                    Image(uiImage: image)
+                        .resizable()
+                        .scaledToFit()
+                        .frame(maxWidth: .infinity, maxHeight: 420)
+                        .contentShape(Rectangle())
+                }
+                .buttonStyle(.plain)
+                .accessibilityLabel("全屏查看照片 \(index + 1)")
+            } else {
+                ContentUnavailableView(
+                    "图片不可用",
+                    systemImage: "photo.badge.exclamationmark",
+                    description: Text("本地图片文件可能已被移动或删除。")
+                )
+                .frame(maxWidth: .infinity, minHeight: 180)
+            }
+
+            ZStack(alignment: .topLeading) {
+                if (noteDrafts[event.id] ?? "").isEmpty {
+                    Text("照片备注")
+                        .font(.system(size: 14))
+                        .foregroundStyle(Color.secondary.opacity(0.55))
+                        .padding(.horizontal, 15)
+                        .padding(.vertical, 17)
+                        .allowsHitTesting(false)
+                }
+
+                TextEditor(text: noteBinding(for: event))
+                    .font(.system(size: 14))
+                    .foregroundStyle(Color.primary)
+                    .scrollContentBackground(.hidden)
+                    .padding(8)
+                    .frame(height: 72)
+            }
+            .background(Color.cardBackground.opacity(0.45))
+            .businessBorder(cornerRadius: 8)
+
+            if index < events.count - 1 {
+                Divider()
+                    .overlay(Color.lineBorder.opacity(0.55))
+                    .padding(.top, 8)
+            }
+        }
+    }
+
+    private func noteBinding(for event: CelestiaTimelineEvent) -> Binding<String> {
+        Binding(
+            get: { noteDrafts[event.id] ?? "" },
+            set: { newValue in
+                noteDrafts[event.id] = newValue
+            }
+        )
+    }
+
+    private var hasChanges: Bool {
+        events.contains { event in
+            let original = (event.textContent ?? "")
+                .trimmingCharacters(in: .whitespacesAndNewlines)
+            let draft = (noteDrafts[event.id] ?? "")
+                .trimmingCharacters(in: .whitespacesAndNewlines)
+            return original != draft || event.location != locationDraft
+        }
+    }
+
+    private func resolvedImage(for event: CelestiaTimelineEvent) -> UIImage? {
+        guard let url = AudioPathHelper.resolveURL(for: event.localFilePath) else {
+            return nil
+        }
+        return UIImage(contentsOfFile: url.path)
+    }
 }
 
 private struct TimelineEventDetailSheet: View {
     @Environment(\.dismiss) private var dismiss
 
     let event: CelestiaTimelineEvent
+    let onSaveNote: (String, TimelineLocation?) -> Bool
     let onDelete: () -> Void
 
+    @State private var noteDraft: String
+    @State private var locationDraft: TimelineLocation?
     @State private var showDeleteConfirmation = false
+    @State private var showFullScreenImage = false
+
+    init(
+        event: CelestiaTimelineEvent,
+        onSaveNote: @escaping (String, TimelineLocation?) -> Bool,
+        onDelete: @escaping () -> Void
+    ) {
+        self.event = event
+        self.onSaveNote = onSaveNote
+        self.onDelete = onDelete
+        _noteDraft = State(initialValue: event.textContent ?? "")
+        _locationDraft = State(initialValue: event.location)
+    }
 
     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)
+                VStack(alignment: .leading, spacing: 16) {
+                    Label(event.relativeTimeFormatted, systemImage: "clock")
+                        .font(.system(size: 12, weight: .medium, design: .monospaced))
+                        .foregroundStyle(Color.secondary)
+
+                    eventContent
+
+                    Spacer(minLength: 0)
 
-                        eventContent
+                    HStack {
+                        Spacer()
 
                         Button(role: .destructive) {
                             showDeleteConfirmation = true
                         } label: {
-                            Label("删除这条记录", systemImage: "trash")
-                                .font(.system(size: 14, weight: .medium))
-                                .frame(maxWidth: .infinity)
-                                .padding(.vertical, 12)
+                            Image(systemName: "trash")
+                                .font(.system(size: 13, weight: .medium))
+                                .frame(width: 30, height: 30)
                         }
                         .buttonStyle(.bordered)
+                        .buttonBorderShape(.circle)
                         .tint(.red)
+                        .accessibilityLabel("删除这条记录")
                     }
-                    .padding(20)
                 }
+                .padding(20)
             }
             .navigationTitle(detailTitle)
             .navigationBarTitleDisplayMode(.inline)
             .toolbar {
-                ToolbarItem(placement: .confirmationAction) {
+                ToolbarItem(placement: .cancellationAction) {
                     Button("完成") {
                         dismiss()
                     }
                 }
+
+                if event.eventType == "NOTE" {
+                    ToolbarItem(placement: .confirmationAction) {
+                        Button("保存") {
+                            if onSaveNote(noteDraft, locationDraft) {
+                                dismiss()
+                            }
+                        }
+                        .disabled(
+                            trimmedNoteDraft.isEmpty
+                                || !hasChanges
+                        )
+                    }
+                }
             }
             .confirmationDialog(
                 "确定删除这条记录吗?",
@@ -1136,6 +1572,15 @@ private struct TimelineEventDetailSheet: View {
                 Text(event.eventType == "PHOTO" ? "本地图片文件也会被删除,此操作无法撤销。" : "此操作无法撤销。")
             }
         }
+        .fullScreenCover(isPresented: $showFullScreenImage) {
+            if let image = resolvedImage {
+                FullScreenTimelineImageView(
+                    image: image,
+                    capturedAt: event.createdAt,
+                    note: event.textContent ?? ""
+                )
+            }
+        }
         .presentationDetents(event.eventType == "PHOTO" ? [.medium, .large] : [.medium])
         .presentationDragIndicator(.visible)
         .presentationBackground(Color.spaceBlack)
@@ -1144,17 +1589,19 @@ private struct TimelineEventDetailSheet: View {
     @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)
-                    }
+            if let image = resolvedImage {
+                Button {
+                    showFullScreenImage = true
+                } label: {
+                    Image(uiImage: image)
+                        .resizable()
+                        .scaledToFit()
+                        .frame(maxWidth: .infinity)
+                        .clipShape(RoundedRectangle(cornerRadius: 10))
+                        .contentShape(Rectangle())
+                }
+                .buttonStyle(.plain)
+                .accessibilityLabel("全屏查看图片")
             } else {
                 ContentUnavailableView(
                     "图片不可用",
@@ -1163,8 +1610,32 @@ private struct TimelineEventDetailSheet: View {
                 )
                 .frame(maxWidth: .infinity, minHeight: 180)
             }
+        } else if event.eventType == "NOTE" {
+            VStack(alignment: .leading, spacing: 14) {
+                ZStack(alignment: .topLeading) {
+                    if noteDraft.isEmpty {
+                        Text("这一刻的想法")
+                            .font(.system(size: 15))
+                            .foregroundStyle(Color.secondary.opacity(0.55))
+                            .padding(.horizontal, 18)
+                            .padding(.vertical, 16)
+                            .allowsHitTesting(false)
+                    }
+
+                    TextEditor(text: $noteDraft)
+                        .font(.system(size: 15))
+                        .foregroundStyle(Color.primary)
+                        .scrollContentBackground(.hidden)
+                        .padding(10)
+                        .frame(maxWidth: .infinity, minHeight: 150, alignment: .topLeading)
+                }
+                .background(Color.cardBackground.opacity(0.5))
+                .businessBorder(cornerRadius: 8)
+
+                TimelineLocationButton(location: $locationDraft)
+            }
         } else {
-            Text(event.textContent ?? "暂无笔记内容")
+            Text(event.textContent ?? "暂无记内容")
                 .font(.system(size: 15))
                 .foregroundStyle(Color.primary)
                 .frame(maxWidth: .infinity, alignment: .leading)
@@ -1174,6 +1645,24 @@ private struct TimelineEventDetailSheet: View {
         }
     }
 
+    private var resolvedImage: UIImage? {
+        guard let url = AudioPathHelper.resolveURL(for: event.localFilePath) else {
+            return nil
+        }
+        return UIImage(contentsOfFile: url.path)
+    }
+
+    private var trimmedNoteDraft: String {
+        noteDraft.trimmingCharacters(in: .whitespacesAndNewlines)
+    }
+
+    private var hasChanges: Bool {
+        trimmedNoteDraft
+            != (event.textContent ?? "")
+                .trimmingCharacters(in: .whitespacesAndNewlines)
+            || locationDraft != event.location
+    }
+
     private var detailTitle: String {
         if event.isContinuationMarker {
             return "续录详情"
@@ -1182,6 +1671,179 @@ private struct TimelineEventDetailSheet: View {
     }
 }
 
+private struct FullScreenTimelineImageView: View {
+    @Environment(\.dismiss) private var dismiss
+
+    let image: UIImage
+    let capturedAt: Date
+    let note: String
+
+    var body: some View {
+        ZStack {
+            Color.black.ignoresSafeArea()
+
+            ZoomableTimelineImage(image: image)
+                .ignoresSafeArea()
+
+            VStack(spacing: 0) {
+                HStack {
+                    Spacer()
+
+                    Button {
+                        dismiss()
+                    } label: {
+                        Image(systemName: "xmark")
+                            .font(.system(size: 14, weight: .semibold))
+                            .foregroundStyle(.white)
+                            .frame(width: 34, height: 34)
+                            .background(.black.opacity(0.55), in: Circle())
+                    }
+                    .accessibilityLabel("关闭全屏图片")
+                }
+                .padding(.top, 12)
+                .padding(.trailing, 16)
+
+                Spacer()
+
+                VStack(alignment: .leading, spacing: 6) {
+                    Text(Self.photoDateFormatter.string(from: capturedAt))
+                        .font(.system(size: 12, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.78))
+
+                    if !trimmedNote.isEmpty {
+                        Text(trimmedNote)
+                            .font(.system(size: 15))
+                            .foregroundStyle(.white)
+                            .fixedSize(horizontal: false, vertical: true)
+                    }
+                }
+                .frame(maxWidth: .infinity, alignment: .leading)
+                .padding(.horizontal, 20)
+                .padding(.top, 54)
+                .padding(.bottom, 28)
+                .background(
+                    LinearGradient(
+                        colors: [.clear, .black.opacity(0.82)],
+                        startPoint: .top,
+                        endPoint: .bottom
+                    )
+                )
+            }
+        }
+        .statusBarHidden()
+    }
+
+    private var trimmedNote: String {
+        note.trimmingCharacters(in: .whitespacesAndNewlines)
+    }
+
+    private static let photoDateFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.locale = Locale(identifier: "zh_CN")
+        formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
+        return formatter
+    }()
+}
+
+private struct ZoomableTimelineImage: View {
+    let image: UIImage
+
+    @State private var scale: CGFloat = 1
+    @State private var settledScale: CGFloat = 1
+    @State private var offset: CGSize = .zero
+    @State private var settledOffset: CGSize = .zero
+
+    private let maximumScale: CGFloat = 5
+
+    var body: some View {
+        GeometryReader { proxy in
+            Image(uiImage: image)
+                .resizable()
+                .scaledToFit()
+                .frame(width: proxy.size.width, height: proxy.size.height)
+                .scaleEffect(scale)
+                .offset(offset)
+                .contentShape(Rectangle())
+                .gesture(magnificationGesture(in: proxy.size))
+                .simultaneousGesture(dragGesture(in: proxy.size))
+        }
+        .clipped()
+        .accessibilityLabel("全屏图片,可双指缩放并拖动")
+    }
+
+    private func magnificationGesture(in containerSize: CGSize) -> some Gesture {
+        MagnifyGesture()
+            .onChanged { value in
+                scale = min(max(settledScale * value.magnification, 1), maximumScale)
+                offset = clampedOffset(offset, scale: scale, in: containerSize)
+            }
+            .onEnded { _ in
+                if scale <= 1 {
+                    scale = 1
+                    offset = .zero
+                } else {
+                    offset = clampedOffset(offset, scale: scale, in: containerSize)
+                }
+
+                settledScale = scale
+                settledOffset = offset
+            }
+    }
+
+    private func dragGesture(in containerSize: CGSize) -> some Gesture {
+        DragGesture()
+            .onChanged { value in
+                guard scale > 1 else {
+                    offset = .zero
+                    return
+                }
+
+                let proposedOffset = CGSize(
+                    width: settledOffset.width + value.translation.width,
+                    height: settledOffset.height + value.translation.height
+                )
+                offset = clampedOffset(proposedOffset, scale: scale, in: containerSize)
+            }
+            .onEnded { _ in
+                offset = clampedOffset(offset, scale: scale, in: containerSize)
+                settledOffset = offset
+            }
+    }
+
+    private func clampedOffset(
+        _ proposedOffset: CGSize,
+        scale: CGFloat,
+        in containerSize: CGSize
+    ) -> CGSize {
+        let fittedSize = aspectFitSize(for: image.size, in: containerSize)
+        let horizontalLimit = max(0, (fittedSize.width * scale - containerSize.width) / 2)
+        let verticalLimit = max(0, (fittedSize.height * scale - containerSize.height) / 2)
+
+        return CGSize(
+            width: min(max(proposedOffset.width, -horizontalLimit), horizontalLimit),
+            height: min(max(proposedOffset.height, -verticalLimit), verticalLimit)
+        )
+    }
+
+    private func aspectFitSize(for imageSize: CGSize, in containerSize: CGSize) -> CGSize {
+        guard imageSize.width > 0,
+              imageSize.height > 0,
+              containerSize.width > 0,
+              containerSize.height > 0 else {
+            return .zero
+        }
+
+        let fitScale = min(
+            containerSize.width / imageSize.width,
+            containerSize.height / imageSize.height
+        )
+        return CGSize(
+            width: imageSize.width * fitScale,
+            height: imageSize.height * fitScale
+        )
+    }
+}
+
 private struct AudioShareItem: Identifiable {
     let id = UUID()
     let fileURL: URL

+ 105 - 26
CelestiaTrace/Views/Recording/ActiveRecordingView.swift

@@ -14,34 +14,42 @@ struct ActiveRecordingView: View {
     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 {
@@ -102,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 {
@@ -114,6 +124,7 @@ struct ActiveRecordingView: View {
         .onChange(of: recordingVM.isRecording) { _, isRecording in
             if isRecording {
                 addContinuationMarkerIfNeeded()
+                performPendingInitialActionIfReady()
             }
             guard isRecording, !hasStartedLiveActivity else { return }
             hasStartedLiveActivity = true
@@ -124,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(
@@ -154,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
@@ -449,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)
@@ -467,6 +490,8 @@ struct ActiveRecordingView: View {
                             .businessBorder(cornerRadius: 8)
                     }
 
+                    TimelineLocationButton(location: $noteLocation)
+
                     Spacer()
                 }
                 .padding(20)
@@ -478,6 +503,7 @@ struct ActiveRecordingView: View {
                     Button("取消") {
                         showNoteSheet = false
                         noteText = ""
+                        noteLocation = nil
                     }
                 }
 
@@ -497,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() {
@@ -521,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
     }
 
@@ -547,6 +614,7 @@ struct ActiveRecordingView: View {
     private func presentNoteSheet() {
         pendingNoteTimeMs = max(0, recordingVM.elapsedTime * 1_000)
         noteText = ""
+        noteLocation = nil
         showNoteSheet = true
     }
 
@@ -612,6 +680,7 @@ struct ActiveRecordingView: View {
                 session.markContentModified()
                 try? modelContext.save()
                 scheduleAutomaticSync()
+                RecordingRecoveryStore.clear(sessionID: session.id)
                 
                 HapticManager.trigger(.recordStop)
                 isEndingRecording = false
@@ -629,6 +698,7 @@ struct ActiveRecordingView: View {
                 try? modelContext.save()
             }
             scheduleAutomaticSync()
+            RecordingRecoveryStore.clear(sessionID: session.id)
             HapticManager.trigger(.recordStop)
             isEndingRecording = false
             dismiss()
@@ -695,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
         }
     }
 }