// // AlbumViews.swift // suixinkan // // Created by Codex on 2026/6/24. // import AVKit import PhotosUI import SwiftUI import UniformTypeIdentifiers /// 相册管理页面,展示相册列表、筛选和新建相册入口。 struct AlbumListView: View { @Environment(AccountContext.self) private var accountContext @Environment(AssetsAPI.self) private var assetsAPI @Environment(ToastCenter.self) private var toastCenter @Environment(\.globalLoading) private var globalLoading @State private var viewModel = AlbumListViewModel() @State private var showCreateSheet = false @State private var newAlbumName = "" @State private var newAlbumRemark = "" var body: some View { ScrollView { LazyVStack(spacing: AppMetrics.Spacing.medium) { filterSection contentSection } .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.medium) } .background(Color(hex: 0xF5F7FA)) .navigationTitle("相册管理") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { NavigationLink { AlbumTrailerEntryView() } label: { Image(systemName: "square.and.arrow.up") } .accessibilityLabel("相册预览上传") Button { newAlbumName = "" newAlbumRemark = "" showCreateSheet = true } label: { Image(systemName: "folder.badge.plus") } .accessibilityLabel("新建相册") } } .refreshable { await reload(showLoading: false) } .task(id: accountContext.currentScenic?.id) { await reload(showLoading: viewModel.folders.isEmpty) } .sheet(isPresented: $showCreateSheet) { AlbumCreateFolderSheet(name: $newAlbumName, remark: $newAlbumRemark) { Task { await createAlbum() } } .presentationDetents([.medium]) } } /// 筛选区域,包含搜索和日期输入。 private var filterSection: some View { VStack(spacing: AppMetrics.Spacing.small) { HStack(spacing: AppMetrics.Spacing.small) { Image(systemName: "magnifyingglass") .foregroundStyle(AppDesign.placeholder) TextField("搜索相册名称", text: $viewModel.searchText) .submitLabel(.search) .onSubmit { Task { await reload(showLoading: true) } } Button("搜索") { Task { await reload(showLoading: true) } } .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(AppDesign.primary) } .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) HStack(spacing: AppMetrics.Spacing.small) { TextField("开始日期 yyyy-MM-dd", text: $viewModel.startTime) .textInputAutocapitalization(.never) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) TextField("结束日期 yyyy-MM-dd", text: $viewModel.endTime) .textInputAutocapitalization(.never) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) } Button { Task { await reload(showLoading: true) } } label: { Label("应用筛选", systemImage: "line.3.horizontal.decrease.circle") .frame(maxWidth: .infinity) } .buttonStyle(.bordered) } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } /// 相册列表内容区域。 @ViewBuilder private var contentSection: some View { if viewModel.folders.isEmpty { AssetsEmptyState(title: "暂无相册", message: viewModel.errorMessage ?? "当前景区还没有相册。") } else { ForEach(viewModel.folders) { folder in NavigationLink { AlbumDetailView(folderId: folder.id, summary: folder) } label: { AlbumFolderRow(folder: folder) } .buttonStyle(.plain) .onAppear { guard folder.id == viewModel.folders.last?.id else { return } Task { await viewModel.loadMore(api: assetsAPI, scenicId: accountContext.currentScenic?.id) } } } if viewModel.isLoadingMore { ProgressView() .padding(.vertical, AppMetrics.Spacing.small) } } } /// 重新加载相册列表。 private func reload(showLoading: Bool) async { await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) { await viewModel.reload(api: assetsAPI, scenicId: accountContext.currentScenic?.id) } if let message = viewModel.errorMessage { toastCenter.show(message) } } /// 创建相册。 private func createAlbum() async { let success = await globalLoading.withLoading { await viewModel.createFolder( name: newAlbumName, remark: newAlbumRemark, scenicId: accountContext.currentScenic?.id, api: assetsAPI ) } toastCenter.show(success ? "相册已创建" : (viewModel.errorMessage ?? "创建失败")) if success { showCreateSheet = false } } } /// 相册详情页面,展示图片/视频列表并支持封面、删除和编辑。 struct AlbumDetailView: View { @Environment(AccountContext.self) private var accountContext @Environment(AssetsAPI.self) private var assetsAPI @Environment(ToastCenter.self) private var toastCenter @Environment(\.globalLoading) private var globalLoading @State private var viewModel: AlbumDetailViewModel @State private var actionFile: AlbumFileItem? @State private var previewFile: AlbumFileItem? @State private var showRenameSheet = false @State private var showRemarkSheet = false @State private var showUploadSheet = false @State private var renameText = "" @State private var remarkText = "" /// 初始化相册详情页面。 init(folderId: Int, summary: AlbumFolderItem? = nil) { _viewModel = State(initialValue: AlbumDetailViewModel(folderId: folderId, summary: summary)) } var body: some View { ScrollView { LazyVStack(spacing: AppMetrics.Spacing.medium) { headerSection tabSection filesSection } .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.medium) } .background(Color(hex: 0xF5F7FA)) .navigationTitle(viewModel.folder?.name ?? "相册详情") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { Button { showUploadSheet = true } label: { Image(systemName: "square.and.arrow.up") } .accessibilityLabel("上传预览") Menu { Button("编辑名称") { renameText = viewModel.folder?.name ?? "" showRenameSheet = true } Button("编辑备注") { remarkText = viewModel.folder?.remark ?? "" showRemarkSheet = true } } label: { Image(systemName: "ellipsis.circle") } } } .refreshable { await reload(showLoading: false) } .task(id: accountContext.currentScenic?.id) { await reload(showLoading: viewModel.files.isEmpty) } .confirmationDialog("文件操作", isPresented: actionDialogBinding, presenting: actionFile) { file in Button("预览") { previewFile = file } Button("设为封面") { Task { await setCover(file) } } Button("删除", role: .destructive) { Task { await delete(file) } } } .sheet(item: $previewFile) { file in AlbumPreviewView(file: file) } .sheet(isPresented: $showUploadSheet) { AlbumTrailerUploadView(initialFolderId: viewModel.folderId) { Task { await reload(showLoading: false) } } } .alert("编辑相册名称", isPresented: $showRenameSheet) { TextField("相册名称", text: $renameText) Button("取消", role: .cancel) {} Button("保存") { Task { await rename() } } } .alert("编辑相册备注", isPresented: $showRemarkSheet) { TextField("备注", text: $remarkText) Button("取消", role: .cancel) {} Button("保存") { Task { await updateRemark() } } } } /// 相册头部信息。 private var headerSection: some View { HStack(spacing: AppMetrics.Spacing.medium) { RemoteImage(urlString: viewModel.folder?.coverURLString ?? "", contentMode: .fill) { Image(systemName: "photo.on.rectangle") .font(.system(size: 32, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppDesign.primarySoft) } .frame(width: 92, height: 92) .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { Text(viewModel.folder?.name ?? "相册") .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) .lineLimit(2) Text("图片 \(viewModel.folder?.countImage ?? 0) · 视频 \(viewModel.folder?.countVideo ?? 0)") .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textSecondary) Text(viewModel.folder?.remark.assetsDisplayText ?? "暂无备注") .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) .lineLimit(2) } Spacer() } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } /// 图片/视频分段切换。 private var tabSection: some View { Picker("文件类型", selection: $viewModel.selectedTab) { ForEach(AlbumFileTab.allCases) { tab in Text(tab.title).tag(tab) } } .pickerStyle(.segmented) .onChange(of: viewModel.selectedTab) { _, tab in Task { await viewModel.selectTab(tab, api: assetsAPI, scenicId: accountContext.currentScenic?.id) } } } /// 文件网格内容区域。 @ViewBuilder private var filesSection: some View { if viewModel.files.isEmpty { AssetsEmptyState(title: "暂无\(viewModel.selectedTab.title)", message: viewModel.errorMessage ?? "这个相册还没有\(viewModel.selectedTab.title)文件。") } else { LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: AppMetrics.Spacing.medium)], spacing: AppMetrics.Spacing.medium) { ForEach(viewModel.files) { file in AlbumFileCard(file: file) { previewFile = file } moreAction: { actionFile = file } .onAppear { guard file.id == viewModel.files.last?.id else { return } Task { await viewModel.loadMore(api: assetsAPI, scenicId: accountContext.currentScenic?.id) } } } } if viewModel.isLoadingMore { ProgressView() .padding(.vertical, AppMetrics.Spacing.small) } } } /// 文件操作弹窗绑定。 private var actionDialogBinding: Binding { Binding( get: { actionFile != nil }, set: { isPresented in if !isPresented { actionFile = nil } } ) } /// 重新加载相册详情。 private func reload(showLoading: Bool) async { await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) { await viewModel.reload(api: assetsAPI, scenicId: accountContext.currentScenic?.id) } if let message = viewModel.errorMessage { toastCenter.show(message) } } /// 设置文件为相册封面。 private func setCover(_ file: AlbumFileItem) async { let success = await globalLoading.withLoading { await viewModel.setCover(file: file, scenicId: accountContext.currentScenic?.id, api: assetsAPI) } toastCenter.show(success ? "已设为封面" : (viewModel.errorMessage ?? "设置失败")) } /// 删除相册文件。 private func delete(_ file: AlbumFileItem) async { let success = await globalLoading.withLoading { await viewModel.delete(file: file, scenicId: accountContext.currentScenic?.id, api: assetsAPI) } toastCenter.show(success ? "已删除" : (viewModel.errorMessage ?? "删除失败")) } /// 保存相册名称。 private func rename() async { let success = await globalLoading.withLoading { await viewModel.rename(name: renameText, scenicId: accountContext.currentScenic?.id, api: assetsAPI) } toastCenter.show(success ? "已保存" : (viewModel.errorMessage ?? "保存失败")) } /// 保存相册备注。 private func updateRemark() async { let success = await globalLoading.withLoading { await viewModel.updateRemark(remarkText, scenicId: accountContext.currentScenic?.id, api: assetsAPI) } toastCenter.show(success ? "已保存" : (viewModel.errorMessage ?? "保存失败")) } } /// 相册预览上传入口页面,用于从首页选择相册并上传文件。 struct AlbumTrailerEntryView: View { var body: some View { AlbumTrailerUploadView(initialFolderId: nil, onUploaded: nil) .navigationTitle("相册预览上传") .navigationBarTitleDisplayMode(.inline) } } /// 相册预览上传页面,负责选择相册、本地文件和提交上传。 struct AlbumTrailerUploadView: View { @Environment(AccountContext.self) private var accountContext @Environment(AssetsAPI.self) private var assetsAPI @Environment(OSSUploadService.self) private var uploadService @Environment(ToastCenter.self) private var toastCenter @Environment(\.dismiss) private var dismiss @Environment(\.globalLoading) private var globalLoading @State private var viewModel = AlbumTrailerViewModel() @State private var selectedItems: [PhotosPickerItem] = [] let initialFolderId: Int? let onUploaded: (() -> Void)? var body: some View { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { folderPickerSection filePickerSection selectedFilesSection submitButton } .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.medium) } .background(Color(hex: 0xF5F7FA)) .task(id: accountContext.currentScenic?.id) { await viewModel.loadFolders(api: assetsAPI, scenicId: accountContext.currentScenic?.id) if let initialFolderId { viewModel.selectedFolderId = initialFolderId } if let message = viewModel.errorMessage { toastCenter.show(message) } } .onChange(of: selectedItems) { _, items in Task { await loadPickedFiles(items) } } } /// 相册选择区域。 private var folderPickerSection: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { Text("选择相册") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) Picker("选择相册", selection: Binding( get: { viewModel.selectedFolderId ?? 0 }, set: { viewModel.selectedFolderId = $0 == 0 ? nil : $0 } )) { Text("请选择相册").tag(0) ForEach(viewModel.folders) { folder in Text(folder.name).tag(folder.id) } } .pickerStyle(.menu) .frame(maxWidth: .infinity, alignment: .leading) .padding(AppMetrics.Spacing.small) .background(Color(hex: 0xF5F7FA), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } /// 本地文件选择区域。 private var filePickerSection: some View { PhotosPicker(selection: $selectedItems, maxSelectionCount: 20, matching: .any(of: [.images, .videos])) { Label("选择图片或视频", systemImage: "photo.badge.plus") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .frame(maxWidth: .infinity) .padding(.vertical, AppMetrics.Spacing.medium) } .buttonStyle(.borderedProminent) } /// 已选本地文件列表。 @ViewBuilder private var selectedFilesSection: some View { if viewModel.localFiles.isEmpty { AssetsEmptyState(title: "还未选择文件", message: "可一次选择最多 20 个图片或视频上传到相册。") } else { VStack(spacing: AppMetrics.Spacing.small) { ForEach(viewModel.localFiles) { file in HStack { Image(systemName: file.fileType == 1 ? "play.rectangle.fill" : "photo.fill") .foregroundStyle(AppDesign.primary) Text(file.fileName) .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) .lineLimit(1) Spacer() Button { viewModel.removeLocalFile(id: file.id) } label: { Image(systemName: "xmark.circle.fill") .frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea) } .foregroundStyle(AppDesign.placeholder) } } if viewModel.isSubmitting { ProgressView(value: Double(viewModel.uploadProgress), total: 100) } } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } } /// 上传提交按钮。 private var submitButton: some View { Button { Task { await submit() } } label: { Text(viewModel.isSubmitting ? "上传中..." : "开始上传") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .frame(maxWidth: .infinity) .padding(.vertical, AppMetrics.Spacing.small) } .buttonStyle(.borderedProminent) .disabled(viewModel.isSubmitting) } /// 读取 PhotosPicker 选择结果。 private func loadPickedFiles(_ items: [PhotosPickerItem]) async { guard !items.isEmpty else { return } defer { selectedItems = [] } let files = await AssetPickerLoader.loadAlbumFiles(from: items) viewModel.addLocalFiles(files) } /// 提交相册预览上传。 private func submit() async { let success = await globalLoading.withLoading { await viewModel.submit( scenicId: accountContext.currentScenic?.id, api: assetsAPI, uploadService: uploadService ) } toastCenter.show(success ? "上传完成" : (viewModel.errorMessage ?? "上传失败")) if success { onUploaded?() dismiss() } } } /// 新建相册表单弹窗。 private struct AlbumCreateFolderSheet: View { @Binding var name: String @Binding var remark: String let submitAction: () -> Void @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Form { TextField("相册名称", text: $name) TextField("备注", text: $remark, axis: .vertical) .lineLimit(3, reservesSpace: true) } .navigationTitle("新建相册") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("取消") { dismiss() } } ToolbarItem(placement: .topBarTrailing) { Button("创建", action: submitAction) } } } } } /// 相册文件夹行,展示封面、名称、数量和备注。 private struct AlbumFolderRow: View { let folder: AlbumFolderItem var body: some View { HStack(spacing: AppMetrics.Spacing.medium) { RemoteImage(urlString: folder.coverURLString, contentMode: .fill) { Image(systemName: "photo.on.rectangle") .font(.system(size: 30, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppDesign.primarySoft) } .frame(width: 72, height: 72) .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) { Text(folder.name.assetsDisplayText) .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) .lineLimit(2) Text("图片 \(folder.countImage) · 视频 \(folder.countVideo)") .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) Text(folder.remark.assetsDisplayText) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) .lineLimit(1) } Spacer() Image(systemName: "chevron.right") .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) .foregroundStyle(AppDesign.placeholder) } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } } /// 相册文件卡片,展示缩略图和操作按钮。 private struct AlbumFileCard: View { let file: AlbumFileItem let openAction: () -> Void let moreAction: () -> Void var body: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { Button(action: openAction) { ZStack(alignment: .center) { RemoteImage(urlString: file.previewURLString, contentMode: .fill) { Image(systemName: file.isVideo ? "play.rectangle.fill" : "photo") .font(.system(size: 30, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppDesign.primarySoft) } if file.isVideo { Image(systemName: "play.circle.fill") .font(.system(size: 36, weight: .semibold)) .foregroundStyle(.white) .shadow(radius: 4) } } .aspectRatio(1.2, contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) } .buttonStyle(.plain) HStack(spacing: AppMetrics.Spacing.xSmall) { VStack(alignment: .leading, spacing: 2) { Text(file.fileName.assetsDisplayText) .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) .lineLimit(1) Text(file.displayFileSize) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } Spacer() Button(action: moreAction) { Image(systemName: "ellipsis") .frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea) } .buttonStyle(.plain) .foregroundStyle(AppDesign.textSecondary) } } .padding(AppMetrics.Spacing.small) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } } /// 相册文件预览页,支持图片和视频。 struct AlbumPreviewView: View { let file: AlbumFileItem @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Group { if file.isVideo, let url = URL(string: file.fileUrl) { VideoPlayer(player: AVPlayer(url: url)) } else { RemoteImage(urlString: file.previewURLString, contentMode: .fit) { AssetsEmptyState(title: "无法预览", message: file.fileName) } .padding() } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.black.opacity(file.isVideo ? 1 : 0.04)) .navigationTitle(file.fileName.assetsDisplayText) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("关闭") { dismiss() } } } } } } extension AssetPickerLoader { /// 加载相册预览上传本地文件。 @MainActor static func loadAlbumFiles(from items: [PhotosPickerItem]) async -> [AlbumLocalUploadFile] { var result: [AlbumLocalUploadFile] = [] for item in items { guard let data = try? await item.loadTransferable(type: Data.self) else { continue } let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) } let ext = isVideo ? "mp4" : "jpg" let fileName = "album_\(UUID().uuidString).\(ext)" result.append(AlbumLocalUploadFile(data: data, fileName: fileName, fileType: isVideo ? 1 : 2)) } return result } } private extension String { /// 返回非空展示文本。 var assetsDisplayText: String { let text = trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? "--" : text } }