Add sample management and upload flows to Assets module.

Extend media library for sample kind with project selection, wire home routing for sample entries, and expand Assets tests.

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

View File

@ -8,7 +8,7 @@
import Foundation
import Observation
/// 便 ViewModel
/// 便 ViewModel
@MainActor
protocol AssetsServing {
///
@ -35,27 +35,30 @@ protocol AssetsServing {
///
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse
///
///
func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse
///
func mediaAlbumDetail(id: Int) async throws -> MediaLibraryDetail
///
func mediaAlbumDetail(id: Int, kind: MediaLibraryKind) async throws -> MediaLibraryDetail
///
///
func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws
///
///
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws
///
func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws
///
///
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws
///
func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws
///
func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem>
///
func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload<AlbumFolderItem>
@ -163,7 +166,7 @@ final class AssetsAPI: AssetsServing {
)
}
///
///
func mediaAlbumList(
kind: MediaLibraryKind = .material,
keyword: String = "",
@ -185,25 +188,28 @@ final class AssetsAPI: AssetsServing {
)
}
///
func mediaAlbumDetail(id: Int) async throws -> MediaLibraryDetail {
///
func mediaAlbumDetail(id: Int, kind: MediaLibraryKind = .material) async throws -> MediaLibraryDetail {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/media-album/detail",
queryItems: [URLQueryItem(name: "id", value: "\(id)")]
queryItems: [
URLQueryItem(name: "id", value: "\(id)"),
URLQueryItem(name: "type", value: "\(kind.rawValue)")
]
)
)
}
///
///
func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/app/media-album/operation", body: request)
) as EmptyPayload
}
///
///
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/app/media-album/delete", body: request)
@ -217,7 +223,7 @@ final class AssetsAPI: AssetsServing {
) as EmptyPayload
}
///
///
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/app/media-album/upload", body: request)
@ -231,6 +237,26 @@ final class AssetsAPI: AssetsServing {
) as EmptyPayload
}
///
func projectList(
scenicId: Int,
name: String? = nil,
page: Int = 1,
pageSize: Int = 50
) async throws -> ListPayload<PhotographerProjectItem> {
var query = [
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
if let name, !name.isEmpty {
query.append(URLQueryItem(name: "name", value: name))
}
return try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/project/list", queryItems: query)
)
}
///
func albumFolderList(
scenicId: Int,

View File

@ -2,7 +2,7 @@
## 模块职责
Assets 模块负责首页中的相册、相册云盘素材管理入口。
Assets 模块负责首页中的相册、相册云盘素材管理和样片管理入口。
- `album_list` 进入 `AlbumListView`
- `album_trailer` 进入 `AlbumTrailerEntryView`
@ -10,8 +10,10 @@ Assets 模块负责首页中的相册、相册云盘和素材管理入口。
- `cloud_storage_transit` 进入 `CloudStorageTransitView`
- `asset_management` 进入 `MediaLibraryView(kind: .material)`
- `material_upload` 进入 `MediaLibraryUploadView`
- `sample_management` 进入 `MediaLibraryView(kind: .sample)`
- `sample_upload` 进入 `MediaLibraryUploadView(kind: .sample)`
`sample_management``sample_upload` 本轮仍保持占位,后续按样片管理独立迁移
样片复用媒体库列表、详情、上下架、删除和 OSS 上传链路;上传样片额外要求选择关联项目
## 相册管理逻辑
@ -56,32 +58,33 @@ Assets 模块负责首页中的相册、相册云盘和素材管理入口。
下载文件只保存到 App 沙盒 `Documents/CloudDownloads`,不自动写入系统相册。上传/下载记录由 `CloudTransferStore` 保存,生命周期仅限本次 App 会话,不落盘。
## 素材管理逻辑
## 素材/样片管理逻辑
`MediaLibraryViewModel` 管理素材列表、关键词、审核状态、分页、详情、删除和上下架。
`MediaLibraryViewModel``MediaLibraryKind` 管理素材或样片列表、关键词、审核状态、分页、详情、删除和上下架。素材接口使用 `type = 1`,样片接口使用 `type = 2`
素材上下架规则:
媒体库上下架规则:
- 审核通过的素材可以上下架。
- 审核未通过或待审核素材禁止上下架。
- 操作成功后刷新素材列表。
- 审核通过的素材或样片可以上下架。
- 审核未通过或待审核素材或样片禁止上下架。
- 操作成功后刷新当前媒体库列表。
`MediaLibraryEditorViewModel` 管理上传和编辑表单。提交前会校验素材名称、当前景区、打卡点、封面、媒体文件和标签长度。
`MediaLibraryEditorViewModel` 管理上传和编辑表单。提交前会校验名称、当前景区、打卡点、封面、媒体文件和标签长度。样片上传还会加载当前景区下的项目列表,并强制选择关联项目。
素材上传流程:
素材/样片上传流程:
1. 页面通过 `PhotosPicker` 选择封面和媒体文件。
2. 打卡点来源于 `ScenicSpotContext.spots`
3. 提交时先把封面和媒体文件上传 OSS
4. 再把最终 OSS URL、文件尺寸、文件大小、打卡点 ID 和标签提交给素材接口
5. 上传失败时不提交素材接口。
3. 样片上传从 `projectList` 获取可关联项目,提交时携带 `project_id`
4. 提交时先把封面和媒体文件上传 OSS
5. 再把最终 OSS URL、文件尺寸、文件大小、打卡点 ID、标签和项目 ID 提交给媒体库接口。
6. 上传失败时不提交媒体库接口。
## 缓存边界
相册列表、相册文件列表、云盘文件列表、素材列表、上传进度、下载记录、OSS STS、本地文件数据和素材表单都不进入 `AppSession``AccountContext` 或 TabBar 状态。
相册列表、相册文件列表、云盘文件列表、素材列表、样片列表、项目选择、上传进度、下载记录、OSS STS、本地文件数据和媒体库表单都不进入 `AppSession``AccountContext` 或 TabBar 状态。
图片缓存继续交给 Kingfisher 的 `RemoteImage`。业务模块不自行缓存远程图片文件或 `Data`
## 测试要求
新增相册、云盘素材逻辑时,需要同步补充 API、ViewModel 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。
新增相册、云盘素材或样片逻辑时,需要同步补充 API、ViewModel 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。

View File

@ -301,9 +301,10 @@ struct CloudTransferItem: Identifiable, Equatable {
}
}
///
///
enum MediaLibraryKind: Int, CaseIterable, Identifiable {
case material = 1
case sample = 2
var id: Int { rawValue }
@ -312,6 +313,58 @@ enum MediaLibraryKind: Int, CaseIterable, Identifiable {
switch self {
case .material:
"素材管理"
case .sample:
"样片管理"
}
}
///
var uploadTitle: String {
switch self {
case .material:
"上传素材"
case .sample:
"上传样片"
}
}
///
var detailTitle: String {
switch self {
case .material:
"素材详情"
case .sample:
"样片详情"
}
}
///
var namePlaceholder: String {
switch self {
case .material:
"素材名称"
case .sample:
"样片名称"
}
}
///
var searchPlaceholder: String {
switch self {
case .material:
"搜索素材名称"
case .sample:
"搜索样片名称"
}
}
///
var emptyTitle: String {
switch self {
case .material:
"暂无素材"
case .sample:
"暂无样片"
}
}
}
@ -643,21 +696,24 @@ struct MediaAlbumEditRequest: Encodable, Equatable {
}
}
///
///
struct MediaAlbumOperationRequest: Encodable, Equatable {
let id: Int
let type: Int
let listingStatus: Int
/// 线
enum CodingKeys: String, CodingKey {
case id
case type
case listingStatus = "listing_status"
}
}
///
///
struct MediaAlbumDeleteRequest: Encodable, Equatable {
let id: Int
let type: Int
}
///
@ -665,6 +721,52 @@ struct MediaAlbumAddTagRequest: Encodable, Equatable {
let name: String
}
///
struct PhotographerProjectItem: Decodable, Identifiable, Hashable {
let id: Int
let type: Int
let typeName: String
let status: Int
let statusName: String
let name: String
let coverProject: String
let coverVideo: String
let price: String
let otPrice: String
let priceDeposit: String
/// 线
enum CodingKeys: String, CodingKey {
case id
case type
case typeName = "type_name"
case status
case statusName = "status_name"
case name
case coverProject = "cover_project"
case coverVideo = "cover_video"
case price
case otPrice = "ot_price"
case priceDeposit = "price_deposit"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
type = try container.decodeLossyInt(forKey: .type) ?? 0
typeName = try container.decodeLossyString(forKey: .typeName)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusName = try container.decodeLossyString(forKey: .statusName)
name = try container.decodeLossyString(forKey: .name)
coverProject = try container.decodeLossyString(forKey: .coverProject)
coverVideo = try container.decodeLossyString(forKey: .coverVideo)
price = try container.decodeLossyString(forKey: .price)
otPrice = try container.decodeLossyString(forKey: .otPrice)
priceDeposit = try container.decodeLossyString(forKey: .priceDeposit)
}
}
///
struct MediaLocalUploadFile: Identifiable, Equatable {
let id = UUID()

View File

@ -258,7 +258,7 @@ final class CloudStorageViewModel {
}
}
/// ViewModel
/// ViewModel
@MainActor
@Observable
final class MediaLibraryViewModel {
@ -277,7 +277,7 @@ final class MediaLibraryViewModel {
private let pageSize = 10
/// ViewModel
/// ViewModel
init(kind: MediaLibraryKind = .material) {
self.kind = kind
}
@ -287,7 +287,7 @@ final class MediaLibraryViewModel {
items.count < total
}
///
///
func reload(api: any AssetsServing) async {
isLoading = true
errorMessage = nil
@ -308,7 +308,7 @@ final class MediaLibraryViewModel {
}
}
///
///
func loadMore(api: any AssetsServing) async {
guard hasMore, !isLoadingMore else { return }
isLoadingMore = true
@ -326,20 +326,20 @@ final class MediaLibraryViewModel {
}
}
///
///
func loadDetail(id: Int, api: any AssetsServing) async {
isLoadingDetail = true
errorMessage = nil
defer { isLoadingDetail = false }
do {
selectedDetail = try await api.mediaAlbumDetail(id: id)
selectedDetail = try await api.mediaAlbumDetail(id: id, kind: kind)
} catch {
errorMessage = error.localizedDescription
}
}
///
///
func toggleListing(_ item: MediaLibraryItem, api: any AssetsServing) async -> Bool {
guard item.isApproved else {
errorMessage = "审核通过后才能上下架"
@ -347,11 +347,11 @@ final class MediaLibraryViewModel {
}
return await operate(api: api) {
let target = item.listing.toggled
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: item.id, listingStatus: target.rawValue))
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: item.id, type: kind.rawValue, listingStatus: target.rawValue))
}
}
///
///
func toggleListing(detail: MediaLibraryDetail, api: any AssetsServing) async -> Bool {
guard detail.isApproved else {
errorMessage = "审核通过后才能上下架"
@ -359,18 +359,18 @@ final class MediaLibraryViewModel {
}
return await operate(api: api) {
let target = detail.listing.toggled
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: detail.id, listingStatus: target.rawValue))
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: detail.id, type: kind.rawValue, listingStatus: target.rawValue))
}
}
///
///
func delete(id: Int, api: any AssetsServing) async -> Bool {
await operate(api: api) {
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: id))
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: id, type: kind.rawValue))
}
}
///
///
private func operate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool {
do {
try await operation()
@ -382,7 +382,7 @@ final class MediaLibraryViewModel {
}
}
///
///
private func requestList(api: any AssetsServing, page: Int) async throws -> MediaLibraryListResponse {
try await api.mediaAlbumList(
kind: kind,
@ -394,17 +394,20 @@ final class MediaLibraryViewModel {
}
}
/// ViewModelOSS
/// ViewModelOSS
@MainActor
@Observable
final class MediaLibraryEditorViewModel {
var name = ""
var description = ""
var selectedSpotId: Int?
var selectedProjectId: Int?
var projects: [PhotographerProjectItem] = []
var tagsText = ""
var coverFile: MediaLocalUploadFile?
var mediaFiles: [MediaLocalUploadFile] = []
var isSubmitting = false
var isLoadingProjects = false
var errorMessage: String?
var didSubmitSuccessfully = false
@ -422,6 +425,29 @@ final class MediaLibraryEditorViewModel {
editingId != nil
}
///
func loadProjects(scenicId: Int?, api: any AssetsServing) async {
guard let scenicId else {
projects = []
errorMessage = "缺少当前景区,无法加载项目"
return
}
isLoadingProjects = true
errorMessage = nil
defer { isLoadingProjects = false }
do {
let payload = try await api.projectList(scenicId: scenicId, name: nil, page: 1, pageSize: 50)
projects = payload.list
if let selectedProjectId, !projects.contains(where: { $0.id == selectedProjectId }) {
self.selectedProjectId = nil
}
} catch {
projects = []
errorMessage = error.localizedDescription
}
}
///
func setCover(_ file: MediaLocalUploadFile) {
coverFile = file
@ -444,7 +470,7 @@ final class MediaLibraryEditorViewModel {
api: any AssetsServing,
uploadService: any OSSUploadServing
) async -> Bool {
guard validate(scenicId: scenicId) else { return false }
guard validate(kind: kind, scenicId: scenicId) else { return false }
guard !isSubmitting else { return false }
isSubmitting = true
defer { isSubmitting = false }
@ -478,7 +504,7 @@ final class MediaLibraryEditorViewModel {
scenicSpotId: selectedSpotId ?? 0,
description: description,
materialTag: normalizedTags(),
projectId: 0
projectId: kind == .sample ? (selectedProjectId ?? 0) : 0
)
try await api.mediaAlbumUpload(request)
}
@ -490,27 +516,31 @@ final class MediaLibraryEditorViewModel {
}
}
///
private func validate(scenicId: Int?) -> Bool {
///
private func validate(kind: MediaLibraryKind, scenicId: Int?) -> Bool {
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard scenicId != nil else {
errorMessage = "缺少当前景区,无法提交素材"
errorMessage = "缺少当前景区,无法提交\(kind == .sample ? "样片" : "素材")"
return false
}
guard !title.isEmpty else {
errorMessage = "请输入素材名称"
errorMessage = "请输入\(kind == .sample ? "样片" : "素材")名称"
return false
}
guard selectedSpotId != nil else {
errorMessage = "请选择打卡点"
return false
}
guard kind != .sample || selectedProjectId != nil else {
errorMessage = "请选择关联项目"
return false
}
guard coverFile != nil else {
errorMessage = "请选择封面"
return false
}
guard !mediaFiles.isEmpty else {
errorMessage = "请选择素材文件"
errorMessage = "请选择\(kind == .sample ? "样片" : "素材")文件"
return false
}
guard normalizedTags().count <= 120 else {

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

View File

@ -33,6 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
## 后续迁移
目前 `payment_collection``payment_qr``payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection``permission_apply``permission_apply_status``scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management``task_management_editor``task_create` 已由 `Features/Tasks` 接管,`cloud_management``cloud_storage_transit``asset_management``material_upload``album_list``album_trailer` 已由 `Features/Assets` 接管。`sample_management``sample_upload` 后续按样片模块独立迁移`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement``scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。
目前 `payment_collection``payment_qr``payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection``permission_apply``permission_apply_status``scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management``task_management_editor``task_create` 已由 `Features/Tasks` 接管,`cloud_management``cloud_storage_transit``asset_management``material_upload``album_list``album_trailer``sample_management``sample_upload` 已由 `Features/Assets` 接管`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement``scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。

View File

@ -36,6 +36,8 @@ enum HomeRoute: Hashable {
case cloudStorageTransit
case materialLibrary
case materialUpload
case sampleLibrary
case sampleUpload
case albumList
case albumTrailer
case modulePlaceholder(uri: String, title: String)

View File

@ -103,6 +103,10 @@ enum HomeMenuRouter {
return .destination(.materialLibrary)
case "material_upload":
return .destination(.materialUpload)
case "sample_management":
return .destination(.sampleLibrary)
case "sample_upload":
return .destination(.sampleUpload)
case "album_list":
return .destination(.albumList)
case "album_trailer":
@ -127,8 +131,6 @@ enum HomeMenuRouter {
"pm",
"pm_manager",
"project_edit",
"sample_management",
"sample_upload",
"live_stream_management",
"live_album",
"scenic_settlement",

View File

@ -44,6 +44,10 @@ extension HomeRoute {
MediaLibraryView()
case .materialUpload:
MediaLibraryUploadView()
case .sampleLibrary:
MediaLibraryView(kind: .sample)
case .sampleUpload:
MediaLibraryUploadView(kind: .sample)
case .albumList:
AlbumListView()
case .albumTrailer: