// // WiredCameraTransferView.swift // suixinkan // // Created by Codex on 2026/6/29. // import SwiftUI import UIKit /// 有线传图页,连接相机并上传照片到旅拍相册。 struct WiredCameraTransferView: View { let context: WiredTransferContext @EnvironmentObject private var accountContext: AccountContext @Environment(\.travelAlbumAPI) private var travelAlbumAPI @Environment(\.ossUploadService) private var ossUploadService @EnvironmentObject private var toastCenter: ToastCenter @StateObject private var viewModel: WiredCameraTransferViewModel init(context: WiredTransferContext) { self.context = context _viewModel = StateObject( wrappedValue: WiredCameraTransferViewModel( context: context, cameraService: CameraServiceFactory.make(brand: .sony) ) ) } private let tabTitles = ["全部", "已上传", "失败"] var body: some View { VStack(spacing: 0) { headerSection tabSection photoListSection bottomBar } .background(Color(hex: 0xF5F7FA)) .navigationTitle("有线传图") .navigationBarTitleDisplayMode(.inline) .task { await viewModel.start( api: travelAlbumAPI, ossService: ossUploadService, scenicID: accountContext.currentScenic?.id ?? 0, accountContext: accountContext ) } .onDisappear { Task { await viewModel.stop() } } .onChange(of: viewModel.errorMessage) { message in guard let message, !message.isEmpty else { return } toastCenter.show(message) } .onChange(of: viewModel.connectionState) { state in switch state { case .connected(let name): toastCenter.show("已连接 \(name)") case .disconnected: break case .error(let message): toastCenter.show(message) default: break } } .confirmationDialog("指定上传", isPresented: $viewModel.showSpecifyUploadSheet, titleVisibility: .visible) { Button("上传全部待上传") { Task { await viewModel.batchUploadAll() } } Button("上传选中项") { Task { await viewModel.uploadSelected() } } Button("取消", role: .cancel) {} } } private var headerSection: some View { VStack(spacing: AppMetrics.Spacing.small) { HStack { VStack(alignment: .leading, spacing: 4) { Text(context.albumName) .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) if !context.phone.isEmpty { Text(context.phone) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } } Spacer() connectionBadge } Picker("传输模式", selection: Binding( get: { viewModel.transferModeOption }, set: { newValue in viewModel.selectTransferMode( newValue, api: travelAlbumAPI, ossService: ossUploadService, scenicID: accountContext.currentScenic?.id ?? 0 ) } )) { Text(WiredCameraTransferViewModel.modeLiveCapture).tag(WiredCameraTransferViewModel.modeLiveCapture) Text(WiredCameraTransferViewModel.modePostShootTransfer).tag(WiredCameraTransferViewModel.modePostShootTransfer) } .pickerStyle(.segmented) } .padding(AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.medium) .background(Color.white) } private var connectionBadge: some View { VStack(spacing: 4) { Circle() .stroke(connectionColor, lineWidth: 3) .frame(width: 44, height: 44) .overlay { Image(systemName: "camera.fill") .foregroundStyle(connectionColor) } Text(connectionText) .font(.system(size: 10)) .foregroundStyle(AppDesign.textSecondary) .multilineTextAlignment(.center) .frame(width: 72) } } private var connectionColor: Color { switch viewModel.connectionState { case .connected: AppDesign.success case .error: Color.red case .searching, .connecting: AppDesign.warning case .disconnected: AppDesign.placeholder } } private var connectionText: String { switch viewModel.connectionState { case .connected(let name): name default: viewModel.connectionState.displayText } } private var tabSection: some View { let counts = viewModel.photos.transferTabCounts return HStack(spacing: 0) { ForEach(Array(tabTitles.enumerated()), id: \.offset) { index, title in Button { viewModel.selectedTabIndex = index } label: { VStack(spacing: 4) { Text(title) .font(.system(size: AppMetrics.FontSize.subheadline, weight: viewModel.selectedTabIndex == index ? .semibold : .regular)) Text("\(counts[index])") .font(.system(size: AppMetrics.FontSize.caption)) } .foregroundStyle(viewModel.selectedTabIndex == index ? AppDesign.primary : AppDesign.textSecondary) .frame(maxWidth: .infinity) .padding(.vertical, 10) .background(viewModel.selectedTabIndex == index ? AppDesign.primarySoft : Color.clear) } .buttonStyle(.plain) } } .background(Color.white) } private var photoListSection: some View { let visiblePhotos = viewModel.photos.filtered(byTabIndex: viewModel.selectedTabIndex) return ScrollView { if visiblePhotos.isEmpty { AppContentUnavailableView("暂无照片", systemImage: "photo") .frame(maxWidth: .infinity, minHeight: 260) } else { LazyVStack(spacing: AppMetrics.Spacing.small) { ForEach(visiblePhotos) { photo in photoRow(photo) } } .padding(AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.medium) } } } private func photoRow(_ photo: WiredTransferPhotoItem) -> some View { HStack(spacing: AppMetrics.Spacing.medium) { if viewModel.selectUploadMode, photo.canSelectForSpecifyUpload { Button { viewModel.togglePhotoSelection(id: photo.id) } label: { Image(systemName: viewModel.selectedPhotoIDs.contains(photo.id) ? "checkmark.circle.fill" : "circle") .foregroundStyle(viewModel.selectedPhotoIDs.contains(photo.id) ? AppDesign.primary : AppDesign.placeholder) } .buttonStyle(.plain) } thumbnailView(for: photo) .frame(width: 64, height: 64) .clipShape(RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 4) { Text(photo.fileName) .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) .lineLimit(1) Text(photo.capturedAt) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) HStack { Text(photo.status.displayLabel) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(statusColor(photo.status)) if photo.status == .uploading || photo.status == .transferring { Text("\(photo.progress)%") .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } } } Spacer() if photo.status == .failed { Button("重试") { Task { await viewModel.retryPhoto(id: photo.id) } } .font(.system(size: AppMetrics.FontSize.caption)) } } .padding(AppMetrics.Spacing.medium) .background(Color.white) .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } @ViewBuilder private func thumbnailView(for photo: WiredTransferPhotoItem) -> some View { if photo.thumbnailURL.hasPrefix("file://"), let url = URL(string: photo.thumbnailURL) { if let uiImage = UIImage(contentsOfFile: url.path) { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fill) } else { placeholderThumbnail } } else { RemoteImage(urlString: photo.thumbnailURL) { placeholderThumbnail } } } private var placeholderThumbnail: some View { Color(hex: 0xE5E7EB) .overlay { Image(systemName: "photo") .foregroundStyle(AppDesign.placeholder) } } private func statusColor(_ status: WiredTransferUploadStatus) -> Color { switch status { case .uploaded: AppDesign.success case .failed: Color.red case .uploading, .transferring: AppDesign.warning case .pending: AppDesign.textSecondary } } private var bottomBar: some View { HStack(spacing: AppMetrics.Spacing.small) { Button("同步历史") { Task { await viewModel.syncExistingPhotos() } } .buttonStyle(.bordered) Button("批量上传") { Task { await viewModel.batchUploadAll() } } .buttonStyle(.borderedProminent) Button("指定上传") { viewModel.selectUploadMode = true viewModel.showSpecifyUploadSheet = true } .buttonStyle(.bordered) } .padding(AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.medium) .background(Color.white) } }