// // MediaLibraryViews.swift // suixinkan // // Created by Codex on 2026/6/23. // import PhotosUI import SwiftUI /// 媒体库页面,展示素材或样片列表、筛选、统计和上下架入口。 struct MediaLibraryView: View { let kind: MediaLibraryKind @Environment(AssetsAPI.self) private var assetsAPI @Environment(RouterPath.self) private var router @Environment(ToastCenter.self) private var toastCenter @Environment(\.globalLoading) private var globalLoading @State private var viewModel: MediaLibraryViewModel @State private var selectedItem: MediaLibraryItem? /// 初始化媒体库页面。 init(kind: MediaLibraryKind = .material) { self.kind = kind _viewModel = State(initialValue: MediaLibraryViewModel(kind: kind)) } var body: some View { ScrollView { LazyVStack(spacing: AppMetrics.Spacing.medium) { filterSection statsSection contentSection } .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.medium) } .background(Color(hex: 0xF5F7FA)) .navigationTitle(kind.title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { router.navigate(to: .home(kind == .sample ? .sampleUpload : .materialUpload)) } label: { Image(systemName: "plus") } .accessibilityLabel(kind.uploadTitle) } } .refreshable { await reload(showLoading: false) } .task { guard viewModel.items.isEmpty else { return } await reload(showLoading: true) } .navigationDestination(item: $selectedItem) { item in MediaLibraryDetailView(item: item, listViewModel: viewModel) } } /// 筛选区域,包含关键词和审核状态。 private var filterSection: some View { VStack(spacing: AppMetrics.Spacing.small) { HStack(spacing: AppMetrics.Spacing.small) { Image(systemName: "magnifyingglass") .foregroundStyle(AppDesign.placeholder) TextField(kind.searchPlaceholder, text: $viewModel.keyword) .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) Picker("审核状态", selection: $viewModel.selectedAuditFilter) { ForEach(MediaLibraryAuditFilter.allCases) { filter in Text(filter.title).tag(filter) } } .pickerStyle(.segmented) .onChange(of: viewModel.selectedAuditFilter) { _, _ in Task { await reload(showLoading: true) } } } } /// 统计区域,展示媒体库订单概览。 @ViewBuilder private var statsSection: some View { if let info = viewModel.orderInfo { HStack(spacing: AppMetrics.Spacing.small) { MediaStatCard(title: "订单数", value: "\(info.totalNum)") MediaStatCard(title: "均价", value: String(format: "%.2f", info.avgOrderAmount)) MediaStatCard(title: "退款", value: String(format: "%.2f", info.refundTotal)) } } } /// 列表内容区域。 @ViewBuilder private var contentSection: some View { if viewModel.items.isEmpty { AssetsEmptyState(title: kind.emptyTitle, message: viewModel.errorMessage ?? "当前筛选条件下没有\(kind == .sample ? "样片" : "素材")。") } else { ForEach(viewModel.items) { item in MediaLibraryCard(item: item) { selectedItem = item } toggleAction: { Task { await toggleListing(item) } } .onAppear { guard item.id == viewModel.items.last?.id else { return } Task { await viewModel.loadMore(api: assetsAPI) } } } if viewModel.isLoadingMore { ProgressView() .padding(.vertical, AppMetrics.Spacing.small) } } } /// 重新加载媒体库列表。 private func reload(showLoading: Bool) async { await globalLoading.withOptionalLoading(showLoading) { await viewModel.reload(api: assetsAPI) } if let message = viewModel.errorMessage { toastCenter.show(message) } } /// 切换媒体库上下架状态。 private func toggleListing(_ item: MediaLibraryItem) async { let success = await globalLoading.withLoading { await viewModel.toggleListing(item, api: assetsAPI) } toastCenter.show(success ? "状态已更新" : (viewModel.errorMessage ?? "操作失败")) } } /// 媒体库统计卡片。 private struct MediaStatCard: View { let title: String let value: String var body: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) { Text(title) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) Text(value) .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } } /// 媒体库列表卡片。 private struct MediaLibraryCard: View { let item: MediaLibraryItem let openAction: () -> Void let toggleAction: () -> Void var body: some View { Button(action: openAction) { HStack(spacing: AppMetrics.Spacing.medium) { RemoteImage(urlString: item.coverUrl, contentMode: .fill) { Image(systemName: "photo") .font(.system(size: 28, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppDesign.primarySoft) } .frame(width: 74, height: 74) .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) { Text(item.name) .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) .lineLimit(2) Text(item.scenicSpotName.assetsNonEmpty ?? item.projectName.assetsNonEmpty ?? "未关联打卡点") .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) .lineLimit(1) HStack(spacing: AppMetrics.Spacing.xSmall) { MediaStatusBadge(text: item.status.auditTitle, color: item.status.auditColor) MediaStatusBadge(text: item.listing.title, color: item.listing == .online ? AppDesign.success : AppDesign.placeholder) } } Spacer() Button(action: toggleAction) { Image(systemName: item.listing == .online ? "arrow.down.circle" : "arrow.up.circle") .frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea) } .buttonStyle(.plain) .foregroundStyle(item.isApproved ? AppDesign.primary : AppDesign.placeholder) .disabled(!item.isApproved) } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } .buttonStyle(.plain) } } /// 媒体库状态标签。 private struct MediaStatusBadge: View { let text: String let color: Color var body: some View { Text(text) .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) .foregroundStyle(color) .padding(.horizontal, AppMetrics.Spacing.xSmall) .padding(.vertical, AppMetrics.Spacing.xxSmall) .background(color.opacity(0.1), in: Capsule()) } } /// 媒体库详情页面,展示基础信息、统计、媒体文件和操作按钮。 struct MediaLibraryDetailView: View { let item: MediaLibraryItem let listViewModel: MediaLibraryViewModel @Environment(AssetsAPI.self) private var assetsAPI @Environment(ToastCenter.self) private var toastCenter @Environment(\.dismiss) private var dismiss @Environment(\.globalLoading) private var globalLoading @State private var isEditing = false var body: some View { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { headerSection mediaSection actionSection } .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.medium) } .background(Color(hex: 0xF5F7FA)) .navigationTitle(listViewModel.kind.detailTitle) .navigationBarTitleDisplayMode(.inline) .task { await globalLoading.withOptionalLoading(listViewModel.selectedDetail?.id != item.id) { await listViewModel.loadDetail(id: item.id, api: assetsAPI) } } .sheet(isPresented: $isEditing) { MediaLibraryUploadView(kind: listViewModel.kind, editingDetail: listViewModel.selectedDetail) } } /// 媒体库详情头部信息。 private var headerSection: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { RemoteImage(urlString: currentCoverURL, contentMode: .fill) { Image(systemName: "photo") .font(.system(size: 40, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppDesign.primarySoft) } .frame(height: 180) .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { Text(currentName) .font(.system(size: AppMetrics.FontSize.title2, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) Text(listViewModel.selectedDetail?.description.assetsNonEmpty ?? "暂无描述") .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textSecondary) HStack { MediaStatusBadge(text: currentStatus.auditTitle, color: currentStatus.auditColor) MediaStatusBadge(text: currentListing.title, color: currentListing == .online ? AppDesign.success : AppDesign.placeholder) } if listViewModel.kind == .sample { sampleSummaryBadges } detailMetaRows } } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } /// 媒体库详情元信息。 private var detailMetaRows: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) { if let detail = listViewModel.selectedDetail { MediaDetailMetaRow(title: "项目", value: detail.projectName.assetsNonEmpty ?? "--") MediaDetailMetaRow(title: "景区", value: detail.scenicName.assetsNonEmpty ?? "--") MediaDetailMetaRow(title: "上传人", value: detail.uploaderName.assetsNonEmpty ?? "--") MediaDetailMetaRow(title: "创建时间", value: detail.createdAt.assetsNonEmpty ?? "--") } else { MediaDetailMetaRow(title: "关联", value: item.projectName.assetsNonEmpty ?? item.scenicSpotName.assetsNonEmpty ?? "--") MediaDetailMetaRow(title: "创建时间", value: item.createdAt.assetsNonEmpty ?? "--") } } } /// 样片媒体数量摘要。 private var sampleSummaryBadges: some View { let media = listViewModel.selectedDetail?.mediaList ?? [] let videoCount = media.filter(\.isVideo).count let imageCount = media.count - videoCount return HStack(spacing: AppMetrics.Spacing.xSmall) { MediaStatusBadge(text: "图片 \(imageCount)", color: AppDesign.success) MediaStatusBadge(text: "视频 \(videoCount)", color: AppDesign.warning) MediaStatusBadge(text: "总计 \(media.count)", color: AppDesign.primary) } } /// 媒体库媒体文件区域。 @ViewBuilder private var mediaSection: some View { let media = listViewModel.selectedDetail?.mediaList ?? [] if media.isEmpty { AssetsEmptyState(title: "暂无媒体文件", message: "详情接口暂未返回\(listViewModel.kind == .sample ? "样片" : "素材")文件。") } else { LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) { ForEach(media) { file in VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) { RemoteImage(urlString: file.thumbnailUrl.assetsNonEmpty ?? file.showUrl.assetsNonEmpty ?? file.ossUrl, contentMode: .fill) { Image(systemName: file.isVideo ? "play.rectangle.fill" : "photo") .font(.system(size: 28, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppDesign.primarySoft) } .frame(height: 96) .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) Text(file.originalName) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) .lineLimit(1) } .padding(AppMetrics.Spacing.xSmall) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) } } } } /// 操作按钮区域。 private var actionSection: some View { VStack(spacing: AppMetrics.Spacing.small) { if listViewModel.kind == .material { Button("编辑素材") { isEditing = true } .frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight) .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) .foregroundStyle(.white) } HStack(spacing: AppMetrics.Spacing.small) { Button(currentListing == .online ? "下架" : "上架") { Task { await toggleListing() } } .frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.sheetButtonHeight) .background(Color.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) Button("删除", role: .destructive) { Task { await delete() } } .frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.sheetButtonHeight) .background(Color.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) } } .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) } private var currentName: String { listViewModel.selectedDetail?.name ?? item.name } private var currentCoverURL: String { listViewModel.selectedDetail?.coverUrl ?? item.coverUrl } private var currentStatus: Int { listViewModel.selectedDetail?.status ?? item.status } private var currentListing: MediaListingStatus { listViewModel.selectedDetail?.listing ?? item.listing } /// 切换详情媒体库上下架状态。 private func toggleListing() async { guard let detail = listViewModel.selectedDetail else { return } let success = await globalLoading.withLoading { await listViewModel.toggleListing(detail: detail, api: assetsAPI) } toastCenter.show(success ? "状态已更新" : (listViewModel.errorMessage ?? "操作失败")) } /// 删除媒体库项目并返回列表。 private func delete() async { let success = await globalLoading.withLoading { await listViewModel.delete(id: item.id, api: assetsAPI) } toastCenter.show(success ? "已删除" : (listViewModel.errorMessage ?? "删除失败")) if success { dismiss() } } } /// 媒体库上传或编辑页面,负责选择封面、媒体文件、打卡点和样片项目后提交。 struct MediaLibraryUploadView: View { let kind: MediaLibraryKind let editingDetail: MediaLibraryDetail? @Environment(AccountContext.self) private var accountContext @Environment(ScenicSpotContext.self) private var scenicSpotContext @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: MediaLibraryEditorViewModel @State private var coverSelection: PhotosPickerItem? @State private var mediaSelection: [PhotosPickerItem] = [] /// 初始化媒体库上传或编辑页面。 init(kind: MediaLibraryKind = .material, editingDetail: MediaLibraryDetail? = nil) { self.kind = kind self.editingDetail = editingDetail _viewModel = State(initialValue: MediaLibraryEditorViewModel(detail: editingDetail)) } var body: some View { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { formSection spotSection projectSection coverSection mediaSection submitButton } .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.medium) } .background(Color(hex: 0xF5F7FA)) .navigationTitle(viewModel.isEditing ? "编辑素材" : kind.uploadTitle) .navigationBarTitleDisplayMode(.inline) .task { guard kind == .sample, viewModel.projects.isEmpty else { return } await viewModel.loadProjects(scenicId: accountContext.currentScenic?.id, api: assetsAPI) if let message = viewModel.errorMessage { toastCenter.show(message) } } .onChange(of: coverSelection) { _, item in Task { await loadCover(item) } } .onChange(of: mediaSelection) { _, items in Task { await loadMedia(items) } } } /// 表单基础信息区域。 private var formSection: some View { VStack(spacing: AppMetrics.Spacing.small) { TextField(kind.namePlaceholder, text: $viewModel.name) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) if kind == .material { TextField("标签,用逗号分隔", text: $viewModel.tagsText) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) } TextField("描述", text: $viewModel.description, axis: .vertical) .lineLimit(3...5) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 88) } } /// 样片关联项目选择区域。 @ViewBuilder private var projectSection: some View { if kind == .sample { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { Text("关联项目") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) if viewModel.projects.isEmpty { AssetsEmptyState(title: "暂无可选项目", message: viewModel.isLoadingProjects ? "正在加载项目..." : "当前景区暂未加载到可关联项目。") } else { Picker("关联项目", selection: $viewModel.selectedProjectId) { Text("请选择").tag(nil as Int?) ForEach(viewModel.projects) { project in Text(project.name.assetsNonEmpty ?? "项目\(project.id)").tag(Optional(project.id)) } } .pickerStyle(.menu) .frame(maxWidth: .infinity, alignment: .leading) .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } } } } /// 打卡点选择区域。 private var spotSection: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { Text("打卡点") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) if scenicSpotContext.spots.isEmpty { AssetsEmptyState(title: "暂无打卡点", message: "当前景区暂未加载到可选打卡点。") } else { Picker("打卡点", selection: $viewModel.selectedSpotId) { Text("请选择").tag(nil as Int?) ForEach(scenicSpotContext.spots) { spot in Text(spot.name).tag(Optional(spot.id)) } } .pickerStyle(.menu) .frame(maxWidth: .infinity, alignment: .leading) .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } } } /// 封面选择区域。 private var coverSection: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { Text("封面") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) PhotosPicker(selection: $coverSelection, matching: .images) { ZStack { RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card) .fill(Color.white) if let coverFile = viewModel.coverFile, let image = UIImage(data: coverFile.data) { Image(uiImage: image) .resizable() .scaledToFill() } else if let url = editingDetail?.coverUrl, !url.isEmpty { RemoteImage(urlString: url, contentMode: .fill) { Image(systemName: "photo") .font(.system(size: 34, weight: .semibold)) .foregroundStyle(AppDesign.primary) } } else { Label("选择封面", systemImage: "photo.badge.plus") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.primary) } } .frame(height: 170) .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } } } /// 媒体文件选择区域。 private var mediaSection: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { HStack { Text("媒体文件") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) Spacer() PhotosPicker(selection: $mediaSelection, maxSelectionCount: 20, matching: .any(of: [.images, .videos])) { Label("添加", systemImage: "plus") } } if viewModel.mediaFiles.isEmpty { AssetsEmptyState(title: "未选择\(kind == .sample ? "样片" : "素材")文件", message: "可选择图片或视频,提交前会先上传到 OSS。") } else { LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) { ForEach(viewModel.mediaFiles) { file in ZStack(alignment: .topTrailing) { MediaLocalPreview(file: file) Button { viewModel.removeMediaFile(id: file.id) } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.white, Color.black.opacity(0.5)) } .padding(AppMetrics.Spacing.xxSmall) } } } } } } /// 提交按钮。 private var submitButton: some View { Button { Task { await submit() } } label: { Text(viewModel.isSubmitting ? "提交中..." : "提交") .frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight) } .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(.white) .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) .disabled(viewModel.isSubmitting) } /// 加载封面选择结果。 private func loadCover(_ item: PhotosPickerItem?) async { guard let item else { return } let files = await AssetPickerLoader.loadMediaFiles(from: [item]) if let file = files.first { viewModel.setCover(file) } } /// 加载媒体选择结果。 private func loadMedia(_ items: [PhotosPickerItem]) async { guard !items.isEmpty else { return } defer { mediaSelection = [] } let files = await AssetPickerLoader.loadMediaFiles(from: items) viewModel.addMediaFiles(files) } /// 提交媒体库表单。 private func submit() async { let success = await globalLoading.withLoading { await viewModel.submit( kind: kind, scenicId: accountContext.currentScenic?.id, api: assetsAPI, uploadService: uploadService ) } toastCenter.show(success ? "\(kind == .sample ? "样片" : "素材")已提交" : (viewModel.errorMessage ?? "提交失败")) if success { dismiss() } } } /// 媒体库详情元信息行。 private struct MediaDetailMetaRow: View { let title: String let value: String var body: some View { HStack(spacing: AppMetrics.Spacing.small) { Text(title) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) Text(value) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textPrimary) .lineLimit(1) Spacer(minLength: 0) } } } /// 本地素材文件预览卡。 private struct MediaLocalPreview: View { let file: MediaLocalUploadFile var body: some View { ZStack { if file.fileType == 2, let image = UIImage(data: file.data) { Image(uiImage: image) .resizable() .scaledToFill() } else { Image(systemName: "play.rectangle.fill") .font(.system(size: 30, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppDesign.primarySoft) } } .frame(height: 96) .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) } } private extension Int { /// 返回素材审核状态文案。 var auditTitle: String { switch self { case 1: "已通过" case 2: "未通过" default: "待审核" } } /// 返回素材审核状态颜色。 var auditColor: Color { switch self { case 1: AppDesign.success case 2: .red default: AppDesign.warning } } }