为资产模块新增样片管理与上传流程
扩展媒体库以支持样片类型及项目选择,为样片入口接入首页路由,并扩展 Assets 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -8,16 +8,24 @@
|
||||
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(kind: .material)
|
||||
@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) {
|
||||
@ -29,16 +37,16 @@ struct MediaLibraryView: View {
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("素材管理")
|
||||
.navigationTitle(kind.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
router.navigate(to: .home(.materialUpload))
|
||||
router.navigate(to: .home(kind == .sample ? .sampleUpload : .materialUpload))
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("上传素材")
|
||||
.accessibilityLabel(kind.uploadTitle)
|
||||
}
|
||||
}
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
@ -57,7 +65,7 @@ struct MediaLibraryView: View {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
TextField("搜索素材名称", text: $viewModel.keyword)
|
||||
TextField(kind.searchPlaceholder, text: $viewModel.keyword)
|
||||
.submitLabel(.search)
|
||||
.onSubmit { Task { await reload(showLoading: true) } }
|
||||
Button("搜索") {
|
||||
@ -80,7 +88,7 @@ struct MediaLibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计区域,展示素材订单概览。
|
||||
/// 统计区域,展示媒体库订单概览。
|
||||
@ViewBuilder
|
||||
private var statsSection: some View {
|
||||
if let info = viewModel.orderInfo {
|
||||
@ -96,7 +104,7 @@ struct MediaLibraryView: View {
|
||||
@ViewBuilder
|
||||
private var contentSection: some View {
|
||||
if viewModel.items.isEmpty {
|
||||
AssetsEmptyState(title: "暂无素材", message: viewModel.errorMessage ?? "当前筛选条件下没有素材。")
|
||||
AssetsEmptyState(title: kind.emptyTitle, message: viewModel.errorMessage ?? "当前筛选条件下没有\(kind == .sample ? "样片" : "素材")。")
|
||||
} else {
|
||||
ForEach(viewModel.items) { item in
|
||||
MediaLibraryCard(item: item) {
|
||||
@ -117,7 +125,7 @@ struct MediaLibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 重新加载素材列表。
|
||||
/// 重新加载媒体库列表。
|
||||
private func reload(showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading) {
|
||||
await viewModel.reload(api: assetsAPI)
|
||||
@ -127,7 +135,7 @@ struct MediaLibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换素材上下架状态。
|
||||
/// 切换媒体库上下架状态。
|
||||
private func toggleListing(_ item: MediaLibraryItem) async {
|
||||
let success = await globalLoading.withLoading {
|
||||
await viewModel.toggleListing(item, api: assetsAPI)
|
||||
@ -136,7 +144,7 @@ struct MediaLibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材统计卡片。
|
||||
/// 媒体库统计卡片。
|
||||
private struct MediaStatCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
@ -156,7 +164,7 @@ private struct MediaStatCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材列表卡片。
|
||||
/// 媒体库列表卡片。
|
||||
private struct MediaLibraryCard: View {
|
||||
let item: MediaLibraryItem
|
||||
let openAction: () -> Void
|
||||
@ -207,7 +215,7 @@ private struct MediaLibraryCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材状态标签。
|
||||
/// 媒体库状态标签。
|
||||
private struct MediaStatusBadge: View {
|
||||
let text: String
|
||||
let color: Color
|
||||
@ -222,7 +230,7 @@ private struct MediaStatusBadge: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材详情页面,展示基础信息、统计、媒体文件和操作按钮。
|
||||
/// 媒体库详情页面,展示基础信息、统计、媒体文件和操作按钮。
|
||||
struct MediaLibraryDetailView: View {
|
||||
let item: MediaLibraryItem
|
||||
let listViewModel: MediaLibraryViewModel
|
||||
@ -245,7 +253,7 @@ struct MediaLibraryDetailView: View {
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("素材详情")
|
||||
.navigationTitle(listViewModel.kind.detailTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await globalLoading.withOptionalLoading(listViewModel.selectedDetail?.id != item.id) {
|
||||
@ -253,11 +261,11 @@ struct MediaLibraryDetailView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isEditing) {
|
||||
MediaLibraryUploadView(editingDetail: listViewModel.selectedDetail)
|
||||
MediaLibraryUploadView(kind: listViewModel.kind, editingDetail: listViewModel.selectedDetail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材详情头部信息。
|
||||
/// 媒体库详情头部信息。
|
||||
private var headerSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||||
RemoteImage(urlString: currentCoverURL, contentMode: .fill) {
|
||||
@ -281,18 +289,49 @@ struct MediaLibraryDetailView: View {
|
||||
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: "详情接口暂未返回素材文件。")
|
||||
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
|
||||
@ -321,12 +360,14 @@ struct MediaLibraryDetailView: View {
|
||||
/// 操作按钮区域。
|
||||
private var actionSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
Button("编辑素材") {
|
||||
isEditing = true
|
||||
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)
|
||||
}
|
||||
.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 ? "下架" : "上架") {
|
||||
@ -350,7 +391,7 @@ struct MediaLibraryDetailView: View {
|
||||
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 {
|
||||
@ -359,7 +400,7 @@ struct MediaLibraryDetailView: View {
|
||||
toastCenter.show(success ? "状态已更新" : (listViewModel.errorMessage ?? "操作失败"))
|
||||
}
|
||||
|
||||
/// 删除素材并返回列表。
|
||||
/// 删除媒体库项目并返回列表。
|
||||
private func delete() async {
|
||||
let success = await globalLoading.withLoading {
|
||||
await listViewModel.delete(id: item.id, api: assetsAPI)
|
||||
@ -369,8 +410,9 @@ struct MediaLibraryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材上传或编辑页面,负责选择封面、媒体文件和打卡点后提交。
|
||||
/// 媒体库上传或编辑页面,负责选择封面、媒体文件、打卡点和样片项目后提交。
|
||||
struct MediaLibraryUploadView: View {
|
||||
let kind: MediaLibraryKind
|
||||
let editingDetail: MediaLibraryDetail?
|
||||
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@ -385,8 +427,9 @@ struct MediaLibraryUploadView: View {
|
||||
@State private var coverSelection: PhotosPickerItem?
|
||||
@State private var mediaSelection: [PhotosPickerItem] = []
|
||||
|
||||
/// 初始化素材上传或编辑页面。
|
||||
init(editingDetail: MediaLibraryDetail? = nil) {
|
||||
/// 初始化媒体库上传或编辑页面。
|
||||
init(kind: MediaLibraryKind = .material, editingDetail: MediaLibraryDetail? = nil) {
|
||||
self.kind = kind
|
||||
self.editingDetail = editingDetail
|
||||
_viewModel = State(initialValue: MediaLibraryEditorViewModel(detail: editingDetail))
|
||||
}
|
||||
@ -396,6 +439,7 @@ struct MediaLibraryUploadView: View {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
formSection
|
||||
spotSection
|
||||
projectSection
|
||||
coverSection
|
||||
mediaSection
|
||||
submitButton
|
||||
@ -404,8 +448,15 @@ struct MediaLibraryUploadView: View {
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle(viewModel.isEditing ? "编辑素材" : "上传素材")
|
||||
.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) }
|
||||
}
|
||||
@ -417,11 +468,13 @@ struct MediaLibraryUploadView: View {
|
||||
/// 表单基础信息区域。
|
||||
private var formSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
TextField("素材名称", text: $viewModel.name)
|
||||
TextField(kind.namePlaceholder, 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)
|
||||
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)
|
||||
@ -429,6 +482,31 @@ struct MediaLibraryUploadView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 样片关联项目选择区域。
|
||||
@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) {
|
||||
@ -495,7 +573,7 @@ struct MediaLibraryUploadView: View {
|
||||
}
|
||||
|
||||
if viewModel.mediaFiles.isEmpty {
|
||||
AssetsEmptyState(title: "未选择素材文件", message: "可选择图片或视频,提交前会先上传到 OSS。")
|
||||
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
|
||||
@ -546,20 +624,40 @@ struct MediaLibraryUploadView: View {
|
||||
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 ? "素材已提交" : (viewModel.errorMessage ?? "提交失败"))
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user