为资产模块新增样片管理与上传流程

扩展媒体库以支持样片类型及项目选择,为样片入口接入首页路由,并扩展 Assets 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 10:06:16 +08:00
parent ae0bc116d8
commit 403a3eefa6
13 changed files with 523 additions and 111 deletions

View File

@ -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