From 403a3eefa63d8d4ef2daf327af218b2bfd4f6e1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 24 Jun 2026 10:06:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BA=E8=B5=84=E4=BA=A7=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=A0=B7=E7=89=87=E7=AE=A1=E7=90=86=E4=B8=8E?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展媒体库以支持样片类型及项目选择,为样片入口接入首页路由,并扩展 Assets 测试。 Co-authored-by: Cursor --- suixinkan/Features/Assets/API/AssetsAPI.swift | 54 ++++-- suixinkan/Features/Assets/Assets.md | 33 ++-- .../Features/Assets/Models/AssetsModels.swift | 108 ++++++++++- .../Assets/ViewModels/AssetsViewModels.swift | 74 +++++--- .../Assets/Views/MediaLibraryViews.swift | 170 ++++++++++++++---- suixinkan/Features/Home/Home.md | 2 +- .../Features/Home/Models/HomeMenuItem.swift | 2 + .../Home/Routing/HomeMenuRouter.swift | 6 +- .../Home/Views/HomeSupportViews.swift | 4 + suixinkanTests/Assets/AssetsAPITests.swift | 58 +++++- .../Assets/AssetsViewModelTests.swift | 97 +++++++++- suixinkanTests/HomeMenuRouterTests.swift | 12 +- 功能同步Checklist.md | 14 +- 13 files changed, 523 insertions(+), 111 deletions(-) diff --git a/suixinkan/Features/Assets/API/AssetsAPI.swift b/suixinkan/Features/Assets/API/AssetsAPI.swift index 489a932..f5d4110 100644 --- a/suixinkan/Features/Assets/API/AssetsAPI.swift +++ b/suixinkan/Features/Assets/API/AssetsAPI.swift @@ -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 + /// 获取相册文件夹列表。 func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload @@ -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 { + 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, diff --git a/suixinkan/Features/Assets/Assets.md b/suixinkan/Features/Assets/Assets.md index 686a0ab..34ca0e9 100644 --- a/suixinkan/Features/Assets/Assets.md +++ b/suixinkan/Features/Assets/Assets.md @@ -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 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。 diff --git a/suixinkan/Features/Assets/Models/AssetsModels.swift b/suixinkan/Features/Assets/Models/AssetsModels.swift index ce50cf0..04319bd 100644 --- a/suixinkan/Features/Assets/Models/AssetsModels.swift +++ b/suixinkan/Features/Assets/Models/AssetsModels.swift @@ -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() diff --git a/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift b/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift index 881500e..ef3f0fb 100644 --- a/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift +++ b/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift @@ -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 { } } -/// 素材上传编辑 ViewModel,负责表单校验、OSS 上传和提交素材。 +/// 媒体库上传编辑 ViewModel,负责表单校验、OSS 上传和提交素材或样片。 @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 { diff --git a/suixinkan/Features/Assets/Views/MediaLibraryViews.swift b/suixinkan/Features/Assets/Views/MediaLibraryViews.swift index a257d1b..e5e69db 100644 --- a/suixinkan/Features/Assets/Views/MediaLibraryViews.swift +++ b/suixinkan/Features/Assets/Views/MediaLibraryViews.swift @@ -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 diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index bb51f5c..0610f99 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -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 的映射,并同步补充单元测试。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index fec95eb..bcbb001 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -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) diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index e48614a..ba11a9c 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -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", diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index 17a239d..903d83e 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -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: diff --git a/suixinkanTests/Assets/AssetsAPITests.swift b/suixinkanTests/Assets/AssetsAPITests.swift index f0fb8de..edbf1f6 100644 --- a/suixinkanTests/Assets/AssetsAPITests.swift +++ b/suixinkanTests/Assets/AssetsAPITests.swift @@ -76,7 +76,7 @@ final class AssetsAPITests: XCTestCase { let api = AssetsAPI(client: APIClient(session: session)) let list = try await api.mediaAlbumList(kind: .material, keyword: "航拍", status: 1, page: 0, pageSize: 0) - let detail = try await api.mediaAlbumDetail(id: 7) + let detail = try await api.mediaAlbumDetail(id: 7, kind: .material) XCTAssertEqual(session.requests.first?.url?.path, "/api/app/media-album/list") let query = assetsQueryItems(from: try XCTUnwrap(session.requests.first)) @@ -87,6 +87,7 @@ final class AssetsAPITests: XCTestCase { XCTAssertEqual(list.order?.totalNum, 12) XCTAssertEqual(list.list.first?.likesCount, 3) XCTAssertEqual(session.requests.last?.url?.path, "/api/app/media-album/detail") + XCTAssertEqual(assetsQueryItems(from: try XCTUnwrap(session.requests.last))["type"], "1") XCTAssertEqual(detail.mediaList.first?.size, 4096) XCTAssertTrue(detail.mediaList.first?.isVideo == true) } @@ -108,8 +109,8 @@ final class AssetsAPITests: XCTestCase { projectId: 0 ) - try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 1, listingStatus: 1)) - try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 1)) + try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 1, type: 1, listingStatus: 1)) + try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 1, type: 1)) try await api.mediaAlbumAddTag(MediaAlbumAddTagRequest(name: "航拍")) try await api.mediaAlbumUpload(upload) try await api.mediaAlbumEdit(MediaAlbumEditRequest(id: 2, name: "素材", type: 1, mediaType: 2, mediaList: upload.mediaList, coverUrl: upload.coverUrl, coverSize: upload.coverSize, scenicSpotId: 6, description: "描述", materialTag: "航拍")) @@ -126,6 +127,52 @@ final class AssetsAPITests: XCTestCase { XCTAssertEqual((body["media_list"] as? [[String: Any]])?.first?["oss_url"] as? String, "https://cdn/a.jpg") } + /// 测试样片请求携带 type=2,项目列表接口和项目字段宽松解码正确。 + func testSampleRequestsAndProjectListUseExpectedTypeAndQuery() async throws { + let session = AssetsRecordingURLSession(responses: [ + Self.mediaListResponse, + Self.mediaDetailResponse, + Self.emptyResponse, + Self.emptyResponse, + Self.projectListResponse, + Self.emptyResponse + ]) + let api = AssetsAPI(client: APIClient(session: session)) + let upload = MediaAlbumUploadRequest( + name: "亲子样片", + type: 2, + mediaType: 2, + mediaList: [MediaAlbumUploadItem(originalName: "sample.jpg", ossUrl: "https://cdn/sample.jpg", size: 10, fileWidthSize: MediaAlbumFileSize(width: 100, height: 80))], + coverUrl: "https://cdn/cover.jpg", + coverSize: MediaAlbumFileSize(width: 100, height: 80), + scenicSpotId: 6, + description: "描述", + materialTag: "", + projectId: 88 + ) + + _ = try await api.mediaAlbumList(kind: .sample, keyword: "样片", status: nil, page: 1, pageSize: 10) + _ = try await api.mediaAlbumDetail(id: 7, kind: .sample) + try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 7, type: 2, listingStatus: 1)) + try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 7, type: 2)) + let projects = try await api.projectList(scenicId: 9, name: "亲子", page: 0, pageSize: 0) + try await api.mediaAlbumUpload(upload) + + XCTAssertEqual(assetsQueryItems(from: session.requests[0])["type"], "2") + XCTAssertEqual(assetsQueryItems(from: session.requests[1])["type"], "2") + XCTAssertEqual((try bodyObject(from: session.requests[2]))["type"] as? Int, 2) + XCTAssertEqual((try bodyObject(from: session.requests[3]))["type"] as? Int, 2) + XCTAssertEqual(session.requests[4].url?.path, "/api/yf-handset-app/photog/project/list") + let projectQuery = assetsQueryItems(from: session.requests[4]) + XCTAssertEqual(projectQuery["scenic_id"], "9") + XCTAssertEqual(projectQuery["name"], "亲子") + XCTAssertEqual(projectQuery["page"], "1") + XCTAssertEqual(projectQuery["page_size"], "1") + XCTAssertEqual(projects.list.first?.id, 88) + XCTAssertEqual(projects.list.first?.name, "亲子旅拍") + XCTAssertEqual((try bodyObject(from: session.requests[5]))["project_id"] as? Int, 88) + } + /// 测试相册接口使用旧工程路径、query 和请求体。 func testAlbumRequestsUseExpectedPathQueryAndBodies() async throws { let session = AssetsRecordingURLSession(responses: [ @@ -205,6 +252,11 @@ final class AssetsAPITests: XCTestCase { {"id":"9","original_name":"v.mp4","oss_url":"https://cdn/v.mp4","thumbnail_url":"","type":"1","size":"4096","show_url":"https://cdn/v.mp4"} ]}} """.data(using: .utf8)! + fileprivate static let projectListResponse = """ + {"code":100000,"msg":"success","data":{"total":"1","list":[ + {"id":"88","type":"19","type_name":"多点旅拍","status":"1","status_name":"上架","name":"亲子旅拍","cover_project":"https://cdn/p.jpg","cover_video":"","price":"299","ot_price":"399","price_deposit":"99"} + ]}} + """.data(using: .utf8)! fileprivate static let albumFolderListResponse = """ {"code":100000,"msg":"success","data":{"total":"1","list":[ {"id":"3","name":"旅拍","cover_file_id":"11","count_video":"2","count_image":"4","cover":{"id":"11","file_name":"a.jpg","file_type":"2","file_url":"https://cdn/a.jpg","cover_url":"","file_size":"2048","file_size_human":"2KB","remark":""},"created_at":"2026","remark":"备注"} diff --git a/suixinkanTests/Assets/AssetsViewModelTests.swift b/suixinkanTests/Assets/AssetsViewModelTests.swift index 77682b0..2bee0f9 100644 --- a/suixinkanTests/Assets/AssetsViewModelTests.swift +++ b/suixinkanTests/Assets/AssetsViewModelTests.swift @@ -95,6 +95,16 @@ final class AssetsViewModelTests: XCTestCase { XCTAssertEqual(viewModel.items.map(\.id), [1, 2]) } + /// 测试样片列表请求使用样片类型。 + func testSampleMediaListUsesSampleKind() async { + let api = MockAssetsService() + let viewModel = MediaLibraryViewModel(kind: .sample) + + await viewModel.reload(api: api) + + XCTAssertEqual(api.mediaListRequests.first?.kind, .sample) + } + /// 测试审核未通过素材禁止上下架。 func testRejectedMediaCannotToggleListing() async { let api = MockAssetsService() @@ -108,6 +118,18 @@ final class AssetsViewModelTests: XCTestCase { XCTAssertEqual(viewModel.errorMessage, "审核通过后才能上下架") } + /// 测试样片上下架会携带样片类型。 + func testSampleToggleListingUsesSampleType() async { + let api = MockAssetsService() + let viewModel = MediaLibraryViewModel(kind: .sample) + let item = MediaLibraryItem.fixture(id: 1, status: 1, listingStatus: 0) + + let success = await viewModel.toggleListing(item, api: api) + + XCTAssertTrue(success) + XCTAssertEqual(api.mediaOperationRequests.first?.type, 2) + } + /// 测试上传素材会先上传封面和媒体文件,再提交最终 OSS URL。 func testMediaUploadUploadsFilesBeforeSubmit() async { let api = MockAssetsService() @@ -145,6 +167,61 @@ final class AssetsViewModelTests: XCTestCase { XCTAssertEqual(api.mediaUploadRequests.count, 0) } + /// 测试样片上传缺少关联项目时不提交。 + func testSampleUploadRequiresProject() async { + let api = MockAssetsService() + let uploader = MockAssetsUploader() + let viewModel = MediaLibraryEditorViewModel() + viewModel.name = "亲子样片" + viewModel.selectedSpotId = 6 + viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80)) + viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)]) + + let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader) + + XCTAssertFalse(success) + XCTAssertEqual(viewModel.errorMessage, "请选择关联项目") + XCTAssertEqual(api.mediaUploadRequests.count, 0) + } + + /// 测试样片上传会先上传文件,再提交样片类型和关联项目。 + func testSampleUploadUploadsFilesAndSubmitsProject() async { + let api = MockAssetsService() + let uploader = MockAssetsUploader() + let viewModel = MediaLibraryEditorViewModel() + viewModel.name = "亲子样片" + viewModel.selectedSpotId = 6 + viewModel.selectedProjectId = 88 + viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80)) + viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)]) + + let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader) + + XCTAssertTrue(success) + XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["cover.jpg", "a.jpg"]) + XCTAssertEqual(api.mediaUploadRequests.first?.type, 2) + XCTAssertEqual(api.mediaUploadRequests.first?.projectId, 88) + } + + /// 测试重复提交样片会被忽略。 + func testSampleDuplicateSubmitIsIgnored() async { + let api = MockAssetsService() + let uploader = MockAssetsUploader() + let viewModel = MediaLibraryEditorViewModel() + viewModel.name = "亲子样片" + viewModel.selectedSpotId = 6 + viewModel.selectedProjectId = 88 + viewModel.isSubmitting = true + viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80)) + viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)]) + + let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader) + + XCTAssertFalse(success) + XCTAssertEqual(api.mediaUploadRequests.count, 0) + XCTAssertEqual(uploader.uploadedFiles.count, 0) + } + /// 测试相册列表无景区时清空数据且不请求接口。 func testAlbumListClearsWithoutScenic() async { let api = MockAssetsService() @@ -274,11 +351,19 @@ private final class MockAssetsService: AssetsServing { } struct MediaListRequest: Equatable { + let kind: MediaLibraryKind let keyword: String let status: Int? let page: Int } + struct ProjectListRequest: Equatable { + let scenicId: Int + let name: String? + let page: Int + let pageSize: Int + } + struct AlbumFolderListRequest: Equatable { let scenicId: Int let page: Int @@ -296,6 +381,7 @@ private final class MockAssetsService: AssetsServing { var cloudPages: [ListPayload] = [ListPayload(total: 0, list: [])] var mediaListResponses: [MediaLibraryListResponse] = [MediaLibraryListResponse.fixture(total: 0, ids: [])] + var projectPages: [ListPayload] = [ListPayload(total: 0, list: [])] var albumFolderPages: [ListPayload] = [ListPayload(total: 0, list: [])] var albumFilePages: [ListPayload] = [ListPayload(total: 0, list: [])] var uploadPermission = true @@ -306,6 +392,7 @@ private final class MockAssetsService: AssetsServing { var mediaOperationRequests: [MediaAlbumOperationRequest] = [] var mediaUploadRequests: [MediaAlbumUploadRequest] = [] var mediaEditRequests: [MediaAlbumEditRequest] = [] + var projectListRequests: [ProjectListRequest] = [] var albumFolderListRequests: [AlbumFolderListRequest] = [] var albumInfoRequests: [Int] = [] var albumFileListRequests: [(scenicId: Int, folderId: Int, fileType: Int, page: Int)] = [] @@ -347,12 +434,12 @@ private final class MockAssetsService: AssetsServing { /// 返回素材列表。 func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse { - mediaListRequests.append(MediaListRequest(keyword: keyword, status: status, page: page)) + mediaListRequests.append(MediaListRequest(kind: kind, keyword: keyword, status: status, page: page)) return mediaListResponses.isEmpty ? MediaLibraryListResponse.fixture(total: 0, ids: []) : mediaListResponses.removeFirst() } /// 返回素材详情。 - func mediaAlbumDetail(id: Int) async throws -> MediaLibraryDetail { + func mediaAlbumDetail(id: Int, kind: MediaLibraryKind) async throws -> MediaLibraryDetail { MediaLibraryDetail.fixture(id: id) } @@ -377,6 +464,12 @@ private final class MockAssetsService: AssetsServing { mediaEditRequests.append(request) } + /// 返回样片可关联项目列表。 + func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload { + projectListRequests.append(ProjectListRequest(scenicId: scenicId, name: name, page: page, pageSize: pageSize)) + return projectPages.isEmpty ? ListPayload(total: 0, list: []) : projectPages.removeFirst() + } + /// 返回相册文件夹列表。 func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload { albumFolderListRequests.append(AlbumFolderListRequest(scenicId: scenicId, page: page, pageSize: pageSize, name: name, startTime: startTime, endTime: endTime)) diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index 1479abf..870b321 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -41,6 +41,8 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "material_upload", title: ""), .destination(.materialUpload)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_trailer", title: ""), .destination(.albumTrailer)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload)) } /// 测试已知但未迁移的首页路由会进入安全占位。 @@ -54,8 +56,8 @@ final class HomeMenuRouterTests: XCTestCase { .destination(.modulePlaceholder(uri: "scenic_settlement_review", title: "结算审核")) ) XCTAssertEqual( - HomeMenuRouter.resolve(uri: "sample_upload", title: ""), - .destination(.modulePlaceholder(uri: "sample_upload", title: "上传样片")) + HomeMenuRouter.resolve(uri: "message_center", title: ""), + .destination(.modulePlaceholder(uri: "message_center", title: "消息中心")) ) } @@ -167,10 +169,8 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertTrue(uris.contains("wallet")) XCTAssertTrue(uris.contains("sample_management")) XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList)) - XCTAssertEqual( - HomeMenuRouter.resolve(uri: "sample_management", title: ""), - .destination(.modulePlaceholder(uri: "sample_management", title: "样片管理")) - ) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload)) } #endif diff --git a/功能同步Checklist.md b/功能同步Checklist.md index 1267d79..0f4e887 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -18,7 +18,7 @@ | 完成 | 全局状态拆分 | `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 | | 完成 | 登录态缓存 | token 使用 Keychain;账号快照、上次手机号、偏好使用 UserDefaults;冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 | | 完成 | 网络框架 | `APIClient`、`APIRequest`、`APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 | -| 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材文件上传处理。 | 后续样片等模块迁移时继续复用。 | +| 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材/样片文件上传处理。 | 后续新上传场景继续复用。 | | 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 | | 完成 | 设计常量 | `AppDesign`、`AppMetrics` 统一颜色、字号、间距等通用尺寸。 | 继续替换新页面中的零散常量。 | | 完成 | 文档规范 | 已有 `AGENTS.md` 约束;现有模块均有模块说明文档。 | 后续每个新功能模块同步新增模块 md。 | @@ -96,8 +96,8 @@ | 完成 | 相册云盘 | 云盘文件/文件夹浏览、搜索筛选、排序、分页、上传、预览、下载、重命名、移动、删除、传输记录。 | 云盘下载只保存到 App 沙盒 `Documents/CloudDownloads`,不写入系统相册。 | | 完成 | 相册管理 | 相册列表、搜索筛选、日期筛选、创建相册、相册详情、图片/视频列表、预览、设置封面、删除文件、编辑名称/备注。 | 后续如旧工程补充更多相册运营动作再继续迁移。 | | 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 | -| 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 样片管理仍按独立模块后续迁移。 | -| 部分完成 | 样片管理 | 入口已识别,OSS 上传服务已具备。 | 迁移样片列表、上传样片。 | +| 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 无。 | +| 完成 | 样片管理 | 样片列表、关键词/审核状态筛选、分页、详情、上下架、删除、上传样片、关联项目选择、样片文件 OSS 上传。 | 完整项目管理、样片编辑、视频播放器增强后续单独迁移。 | | 部分完成 | 直播管理 | 入口已识别,进入占位页。 | 迁移直播管理和直播相册。 | | 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 | | 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 | @@ -112,7 +112,7 @@ | 状态 | 模块 | 说明 | | --- | --- | --- | -| 部分完成 | Assets 模块 | 已迁移相册管理、相册预览上传、相册云盘和素材管理;样片管理、上传样片仍未迁移。 | +| 完成 | Assets 模块 | 已迁移相册管理、相册预览上传、相册云盘、素材管理、样片管理和上传样片。 | | 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 | | 未开始 | Core/Push | 旧工程推送能力尚未迁移。 | | 未开始 | Core/Queue | 旧工程队列基础能力尚未迁移。 | @@ -131,6 +131,6 @@ xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination ## 下一批建议迁移顺序 1. 打卡点管理/位置上报:依赖当前景区和景点上下文,当前上下文已经稳定。 -2. 样片管理/上传样片:与素材管理和相册上传相近,可复用素材列表、上传和标签处理经验。 -3. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾。 -4. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试。 +2. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾。 +3. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试。 +4. 项目管理:样片上传已具备轻量项目选择,后续可迁移完整项目列表、详情和编辑。