将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
709 lines
29 KiB
Swift
709 lines
29 KiB
Swift
//
|
||
// MediaLibraryViews.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/23.
|
||
//
|
||
|
||
import PhotosUI
|
||
import SwiftUI
|
||
|
||
/// 媒体库页面,展示素材或样片列表、筛选、统计和上下架入口。
|
||
struct MediaLibraryView: View {
|
||
let kind: MediaLibraryKind
|
||
|
||
@Environment(\.assetsAPI) private var assetsAPI
|
||
@EnvironmentObject private var router: RouterPath
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
|
||
@StateObject private var viewModel: MediaLibraryViewModel
|
||
@State private var selectedItem: MediaLibraryItem?
|
||
|
||
/// 初始化媒体库页面。
|
||
init(kind: MediaLibraryKind = .material) {
|
||
self.kind = kind
|
||
_viewModel = StateObject(wrappedValue: 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)
|
||
}
|
||
.appNavigationDestination(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) private var assetsAPI
|
||
@EnvironmentObject private var toastCenter: 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?
|
||
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||
@Environment(\.assetsAPI) private var assetsAPI
|
||
@Environment(\.ossUploadService) private var uploadService
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
|
||
@StateObject 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 = StateObject(wrappedValue: 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
|
||
}
|
||
}
|
||
}
|