Files
suixinkan_ios_new/suixinkan/Features/Assets/Views/MediaLibraryViews.swift
汉秋 cac22fcc56 新增资产模块,包含云存储与媒体库流程
为云端与素材管理页面接入首页路由,将共享云存储模型迁入 Assets 模块,并补充 API/ViewModel 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:10:20 +08:00

611 lines
25 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// MediaLibraryViews.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import PhotosUI
import SwiftUI
///
struct MediaLibraryView: View {
@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(kind: .material)
@State private var selectedItem: MediaLibraryItem?
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("素材管理")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
router.navigate(to: .home(.materialUpload))
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("上传素材")
}
}
.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("搜索素材名称", 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: "暂无素材", message: viewModel.errorMessage ?? "当前筛选条件下没有素材。")
} 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("素材详情")
.navigationBarTitleDisplayMode(.inline)
.task {
await globalLoading.withOptionalLoading(listViewModel.selectedDetail?.id != item.id) {
await listViewModel.loadDetail(id: item.id, api: assetsAPI)
}
}
.sheet(isPresented: $isEditing) {
MediaLibraryUploadView(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)
}
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
@ViewBuilder
private var mediaSection: some View {
let media = listViewModel.selectedDetail?.mediaList ?? []
if media.isEmpty {
AssetsEmptyState(title: "暂无媒体文件", message: "详情接口暂未返回素材文件。")
} 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) {
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 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(editingDetail: MediaLibraryDetail? = nil) {
self.editingDetail = editingDetail
_viewModel = State(initialValue: MediaLibraryEditorViewModel(detail: editingDetail))
}
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
formSection
spotSection
coverSection
mediaSection
submitButton
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle(viewModel.isEditing ? "编辑素材" : "上传素材")
.navigationBarTitleDisplayMode(.inline)
.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("素材名称", text: $viewModel.name)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
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)
}
}
///
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: "未选择素材文件", 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(
scenicId: accountContext.currentScenic?.id,
api: assetsAPI,
uploadService: uploadService
)
}
toastCenter.show(success ? "素材已提交" : (viewModel.errorMessage ?? "提交失败"))
if success { dismiss() }
}
}
///
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
}
}
}