Add Assets module with cloud storage and media library flows.

Wire home routing for cloud and material management pages, move shared cloud models into Assets, and add API/view model tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 16:10:20 +08:00
parent 3e898d93d0
commit a48eb76ba4
15 changed files with 3431 additions and 75 deletions

View File

@ -0,0 +1,610 @@
//
// 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
}
}
}