diff --git a/suixinkan/Features/Assets/API/AssetsAPI.swift b/suixinkan/Features/Assets/API/AssetsAPI.swift index 542d9ff..489a932 100644 --- a/suixinkan/Features/Assets/API/AssetsAPI.swift +++ b/suixinkan/Features/Assets/API/AssetsAPI.swift @@ -55,6 +55,27 @@ protocol AssetsServing { /// 编辑素材。 func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws + + /// 获取相册文件夹列表。 + func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload + + /// 获取相册文件夹详情。 + func albumFolderInfo(id: Int) async throws -> AlbumFolderItem + + /// 获取相册内文件列表。 + func albumFileList(scenicId: Int, folderId: Int, fileType: Int, page: Int, pageSize: Int) async throws -> ListPayload + + /// 新建相册文件夹。 + func addAlbumFolder(scenicId: Int, name: String, remark: String) async throws + + /// 编辑相册文件夹名称、备注或封面。 + func editAlbumFolder(folderId: Int, coverFileId: Int?, name: String?, remark: String?) async throws + + /// 删除相册内文件。 + func deleteAlbumFiles(folderId: Int, idList: [Int]) async throws + + /// 登记已上传到 OSS 的相册文件。 + func albumFileUploadURL(scenicId: Int, fileURL: String, folderId: Int) async throws } /// 资产 API,负责封装相册云盘和素材管理相关接口。 @@ -209,4 +230,120 @@ final class AssetsAPI: AssetsServing { APIRequest(method: .post, path: "/api/app/media-album/edit", body: request) ) as EmptyPayload } + + /// 获取相册文件夹列表。 + func albumFolderList( + scenicId: Int, + page: Int = 1, + pageSize: Int = 10, + name: String = "", + startTime: String? = nil, + endTime: String? = nil + ) 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))"), + URLQueryItem(name: "cloud_folder_type", value: "1") + ] + if !name.isEmpty { + query.append(URLQueryItem(name: "name", value: name)) + } + if let startTime, !startTime.isEmpty { + query.append(URLQueryItem(name: "start_time", value: startTime)) + } + if let endTime, !endTime.isEmpty { + query.append(URLQueryItem(name: "end_time", value: endTime)) + } + return try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/album/folder-list", queryItems: query) + ) + } + + /// 获取相册文件夹详情。 + func albumFolderInfo(id: Int) async throws -> AlbumFolderItem { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/album/folder-info", + queryItems: [URLQueryItem(name: "id", value: "\(id)")] + ) + ) + } + + /// 获取相册内文件列表。 + func albumFileList( + scenicId: Int, + folderId: Int, + fileType: Int, + page: Int = 1, + pageSize: Int = 20 + ) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/album/file-list", + queryItems: [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "folder_id", value: "\(folderId)"), + URLQueryItem(name: "file_type", value: "\(fileType)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + ) + ) + } + + /// 新建相册文件夹。 + func addAlbumFolder(scenicId: Int, name: String, remark: String) async throws { + _ = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/album/folder-add", + body: AlbumFolderAddRequest( + scenicId: "\(scenicId)", + name: name, + remark: remark, + cloudFolderType: 1 + ) + ) + ) as EmptyPayload + } + + /// 编辑相册文件夹名称、备注或封面。 + func editAlbumFolder(folderId: Int, coverFileId: Int? = nil, name: String? = nil, remark: String? = nil) async throws { + _ = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/album/folder-edit", + body: AlbumFolderEditRequest(folderId: folderId, coverFileId: coverFileId, name: name, remark: remark) + ) + ) as EmptyPayload + } + + /// 删除相册内文件。 + func deleteAlbumFiles(folderId: Int, idList: [Int]) async throws { + _ = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/album/file-delete", + body: AlbumFileDeleteRequest(idList: idList, folderId: folderId) + ) + ) as EmptyPayload + } + + /// 登记已上传到 OSS 的相册文件。 + func albumFileUploadURL(scenicId: Int, fileURL: String, folderId: Int) async throws { + _ = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/album/file-upload-url", + queryItems: [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "file_url", value: fileURL), + URLQueryItem(name: "folder_id", value: "\(folderId)") + ] + ) + ) as EmptyPayload + } } diff --git a/suixinkan/Features/Assets/Assets.md b/suixinkan/Features/Assets/Assets.md index a89d969..686a0ab 100644 --- a/suixinkan/Features/Assets/Assets.md +++ b/suixinkan/Features/Assets/Assets.md @@ -2,14 +2,45 @@ ## 模块职责 -Assets 模块负责首页中的相册云盘和素材管理入口。 +Assets 模块负责首页中的相册、相册云盘和素材管理入口。 +- `album_list` 进入 `AlbumListView`。 +- `album_trailer` 进入 `AlbumTrailerEntryView`。 - `cloud_management` 进入 `CloudStorageView`。 - `cloud_storage_transit` 进入 `CloudStorageTransitView`。 - `asset_management` 进入 `MediaLibraryView(kind: .material)`。 - `material_upload` 进入 `MediaLibraryUploadView`。 -`album_list`、`album_trailer`、`sample_management`、`sample_upload` 本轮仍保持占位,后续按相册管理、相册预览上传、样片管理独立迁移。 +`sample_management`、`sample_upload` 本轮仍保持占位,后续按样片管理独立迁移。 + +## 相册管理逻辑 + +`AlbumListViewModel` 只保存相册列表、搜索条件、日期筛选、分页和创建状态。缺少当前景区时清空列表并停止请求接口。 + +相册列表流程: + +1. 页面从 `AccountContext.currentScenic` 读取当前景区 ID。 +2. 调用 `albumFolderList` 获取 `cloud_folder_type = 1` 的相册文件夹。 +3. 支持按相册名称、开始时间、结束时间筛选。 +4. 新建相册调用 `addAlbumFolder`,成功后刷新第一页。 + +`AlbumDetailViewModel` 管理单个相册的信息和文件列表。图片和视频通过 `AlbumFileTab` 区分,图片使用 `file_type = 2`,视频使用 `file_type = 1`。详情页支持编辑相册名称、编辑备注、设置封面和删除相册文件,操作成功后刷新详情。 + +相册文件预览使用 `RemoteImage` 展示图片,视频使用系统 `VideoPlayer` 播放。 + +## 相册预览上传逻辑 + +`AlbumTrailerViewModel` 管理相册选择、本地图片/视频选择、上传进度和提交状态。 + +上传流程: + +1. 页面加载当前景区下的相册列表。 +2. 用户通过 `PhotosPicker` 选择图片或视频。 +3. 提交时先调用 `OSSUploadService.uploadAlbumFile` 上传到 OSS。 +4. 上传成功后调用 `albumFileUploadURL` 把文件 URL 写入相册。 +5. 任一文件上传失败时停止后续入库,并保留错误提示。 + +本地文件数据、OSS STS 和上传进度只保存在当前上传流程内,不落盘。 ## 云盘逻辑 @@ -47,10 +78,10 @@ Assets 模块负责首页中的相册云盘和素材管理入口。 ## 缓存边界 -云盘文件列表、素材列表、上传进度、下载记录、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 a4bfa6b..ce50cf0 100644 --- a/suixinkan/Features/Assets/Models/AssetsModels.swift +++ b/suixinkan/Features/Assets/Models/AssetsModels.swift @@ -675,6 +675,215 @@ struct MediaLocalUploadFile: Identifiable, Equatable { let height: Int } +/// 相册文件夹实体,表示相册管理列表中的一个相册。 +struct AlbumFolderItem: Decodable, Identifiable, Hashable { + let id: Int + let name: String + let coverFileId: Int + let countVideo: Int + let countImage: Int + let coverFile: AlbumFileItem? + let createTime: String + let remark: String + + /// 相册封面地址,优先使用封面文件的预览地址。 + var coverURLString: String { + coverFile?.previewURLString ?? "" + } + + /// 相册内文件总数。 + var totalCount: Int { + countVideo + countImage + } + + /// 字段映射,兼容旧接口下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case name + case coverFileId = "cover_file_id" + case countVideo = "count_video" + case countImage = "count_image" + case coverFile = "cover" + case createTime = "created_at" + case remark + } + + /// 宽松解码相册文件夹字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + name = try container.decodeLossyString(forKey: .name) + coverFileId = try container.decodeLossyInt(forKey: .coverFileId) ?? 0 + countVideo = try container.decodeLossyInt(forKey: .countVideo) ?? 0 + countImage = try container.decodeLossyInt(forKey: .countImage) ?? 0 + coverFile = try? container.decodeIfPresent(AlbumFileItem.self, forKey: .coverFile) + createTime = try container.decodeLossyString(forKey: .createTime) + remark = try container.decodeLossyString(forKey: .remark) + } +} + +/// 相册文件实体,表示相册详情中的图片或视频。 +struct AlbumFileItem: Decodable, Identifiable, Hashable { + let id: Int + let fileName: String + let fileType: Int + let fileUrl: String + let coverUrl: String + let fileSize: Int64 + let fileSizeHuman: String + let remark: String + + /// 判断当前文件是否为视频。 + var isVideo: Bool { + fileType == 1 || Self.hasVideoExtension(fileName) || Self.hasVideoExtension(fileUrl) + } + + /// 判断当前文件是否为图片。 + var isImage: Bool { + fileType == 2 || Self.hasImageExtension(fileName) || Self.hasImageExtension(fileUrl) + } + + /// 返回图片或视频封面的预览地址。 + var previewURLString: String { + coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl + } + + /// 返回文件大小展示文案。 + var displayFileSize: String { + if !fileSizeHuman.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return fileSizeHuman + } + return fileSize.assetsFormattedFileSize + } + + /// 根据文件扩展名判断是否为视频。 + private static func hasVideoExtension(_ value: String) -> Bool { + ["mp4", "mov", "m4v", "avi"].contains(pathExtension(for: value)) + } + + /// 根据文件扩展名判断是否为图片。 + private static func hasImageExtension(_ value: String) -> Bool { + ["png", "jpg", "jpeg", "heic", "heif", "webp"].contains(pathExtension(for: value)) + } + + /// 提取 URL 或文件路径中的扩展名。 + private static func pathExtension(for value: String) -> String { + URL(string: value)?.pathExtension.lowercased() ?? URL(fileURLWithPath: value).pathExtension.lowercased() + } + + /// 字段映射,兼容旧接口下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case fileName = "file_name" + case fileType = "file_type" + case fileUrl = "file_url" + case coverUrl = "cover_url" + case fileSize = "file_size" + case fileSizeHuman = "file_size_human" + case remark + } + + /// 宽松解码相册文件字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + fileName = try container.decodeLossyString(forKey: .fileName) + fileType = try container.decodeLossyInt(forKey: .fileType) ?? 0 + fileUrl = try container.decodeLossyString(forKey: .fileUrl) + coverUrl = try container.decodeLossyString(forKey: .coverUrl) + fileSize = try container.decodeLossyInt64(forKey: .fileSize) ?? 0 + fileSizeHuman = try container.decodeLossyString(forKey: .fileSizeHuman) + remark = try container.decodeLossyString(forKey: .remark) + } +} + +/// 相册文件类型筛选实体,表示相册详情中的图片或视频 Tab。 +enum AlbumFileTab: Int, CaseIterable, Identifiable { + case image = 2 + case video = 1 + + var id: Int { rawValue } + + /// 返回页面展示标题。 + var title: String { + switch self { + case .image: + "图片" + case .video: + "视频" + } + } +} + +/// 新建相册请求实体。 +struct AlbumFolderAddRequest: Encodable, Equatable { + let scenicId: String + let name: String + let remark: String + let cloudFolderType: Int + + /// 请求字段映射,兼容旧接口下划线命名。 + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case name + case remark + case cloudFolderType = "cloud_folder_type" + } +} + +/// 编辑相册请求实体,用于改名、备注和封面。 +struct AlbumFolderEditRequest: Encodable, Equatable { + let folderId: Int + let coverFileId: Int? + let name: String? + let remark: String? + + /// 请求字段映射,兼容旧接口下划线命名。 + enum CodingKeys: String, CodingKey { + case folderId = "id" + case coverFileId = "cover_file_id" + case name + case remark + } +} + +/// 删除相册文件请求实体。 +struct AlbumFileDeleteRequest: Encodable, Equatable { + let idList: [Int] + let folderId: Int + + /// 请求字段映射,兼容旧接口下划线命名。 + enum CodingKeys: String, CodingKey { + case idList = "id_list" + case folderId = "folder_id" + } +} + +/// 相册本地待上传文件实体,表示 PhotosPicker 读取后的图片或视频。 +struct AlbumLocalUploadFile: Identifiable, Equatable { + let id = UUID() + let data: Data + let fileName: String + let fileType: Int +} + +private extension Int64 { + /// 返回资产模块通用文件大小展示文案。 + var assetsFormattedFileSize: String { + let value = Double(self) + if value >= 1_073_741_824 { + return String(format: "%.1fGB", value / 1_073_741_824) + } + if value >= 1_048_576 { + return String(format: "%.1fMB", value / 1_048_576) + } + if value >= 1024 { + return String(format: "%.1fKB", value / 1024) + } + return "\(self)B" + } +} + private extension KeyedDecodingContainer { /// 将 String、数字和 Bool 宽松解码为字符串。 func decodeLossyString(forKey key: Key) throws -> String { diff --git a/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift b/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift index 71410af..881500e 100644 --- a/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift +++ b/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift @@ -578,3 +578,367 @@ final class MediaLibraryEditorViewModel { .joined(separator: ",") } } + +/// 相册列表 ViewModel,负责相册文件夹搜索、日期筛选、分页和新建相册。 +@MainActor +@Observable +final class AlbumListViewModel { + var folders: [AlbumFolderItem] = [] + var total = 0 + var page = 1 + var searchText = "" + var startTime = "" + var endTime = "" + var isLoading = false + var isLoadingMore = false + var isMutating = false + var errorMessage: String? + + private let pageSize = 10 + + /// 判断当前相册列表是否还有下一页。 + var hasMore: Bool { + folders.count < total + } + + /// 重新加载相册第一页。 + func reload(api: any AssetsServing, scenicId: Int?) async { + guard let scenicId else { + folders = [] + total = 0 + page = 1 + errorMessage = "缺少当前景区,无法加载相册" + return + } + + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let payload = try await requestFolders(api: api, scenicId: scenicId, page: 1) + self.page = 1 + folders = payload.list + total = payload.total + } catch { + folders = [] + total = 0 + page = 1 + errorMessage = error.localizedDescription + } + } + + /// 加载相册下一页。 + func loadMore(api: any AssetsServing, scenicId: Int?) async { + guard let scenicId, hasMore, !isLoadingMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + + do { + let nextPage = page + 1 + let payload = try await requestFolders(api: api, scenicId: scenicId, page: nextPage) + page = nextPage + total = payload.total + folders.append(contentsOf: payload.list) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 新建相册并刷新列表。 + func createFolder(name: String, remark: String, scenicId: Int?, api: any AssetsServing) async -> Bool { + let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard let scenicId else { + errorMessage = "缺少当前景区,无法创建相册" + return false + } + guard !folderName.isEmpty else { + errorMessage = "请输入相册名称" + return false + } + guard !isMutating else { return false } + + isMutating = true + defer { isMutating = false } + + do { + try await api.addAlbumFolder(scenicId: scenicId, name: folderName, remark: remark.trimmingCharacters(in: .whitespacesAndNewlines)) + await reload(api: api, scenicId: scenicId) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 组装相册列表请求参数。 + private func requestFolders(api: any AssetsServing, scenicId: Int, page: Int) async throws -> ListPayload { + try await api.albumFolderList( + scenicId: scenicId, + page: page, + pageSize: pageSize, + name: searchText.trimmingCharacters(in: .whitespacesAndNewlines), + startTime: startTime.trimmingCharacters(in: .whitespacesAndNewlines).assetsNilIfEmpty, + endTime: endTime.trimmingCharacters(in: .whitespacesAndNewlines).assetsNilIfEmpty + ) + } +} + +/// 相册详情 ViewModel,负责相册信息、图片/视频列表和文件操作。 +@MainActor +@Observable +final class AlbumDetailViewModel { + let folderId: Int + var folder: AlbumFolderItem? + var files: [AlbumFileItem] = [] + var selectedTab: AlbumFileTab = .image + var total = 0 + var page = 1 + var isLoading = false + var isLoadingMore = false + var isMutating = false + var errorMessage: String? + + private let pageSize = 20 + + /// 初始化相册详情 ViewModel。 + init(folderId: Int, summary: AlbumFolderItem? = nil) { + self.folderId = folderId + folder = summary + } + + /// 判断当前文件列表是否还有下一页。 + var hasMore: Bool { + files.count < total + } + + /// 重新加载相册信息和当前 Tab 文件列表。 + func reload(api: any AssetsServing, scenicId: Int?) async { + guard let scenicId else { + files = [] + total = 0 + page = 1 + errorMessage = "缺少当前景区,无法加载相册内容" + return + } + + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + folder = try await api.albumFolderInfo(id: folderId) + let listPayload = try await api.albumFileList( + scenicId: scenicId, + folderId: folderId, + fileType: selectedTab.rawValue, + page: 1, + pageSize: pageSize + ) + files = listPayload.list + total = listPayload.total + page = 1 + } catch { + page = 1 + errorMessage = error.localizedDescription + } + } + + /// 切换图片或视频列表并重新加载第一页。 + func selectTab(_ tab: AlbumFileTab, api: any AssetsServing, scenicId: Int?) async { + guard selectedTab != tab else { return } + selectedTab = tab + files = [] + total = 0 + page = 1 + await reload(api: api, scenicId: scenicId) + } + + /// 加载当前 Tab 下一页文件。 + func loadMore(api: any AssetsServing, scenicId: Int?) async { + guard let scenicId, hasMore, !isLoadingMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + + do { + let nextPage = page + 1 + let payload = try await api.albumFileList( + scenicId: scenicId, + folderId: folderId, + fileType: selectedTab.rawValue, + page: nextPage, + pageSize: pageSize + ) + page = nextPage + total = payload.total + files.append(contentsOf: payload.list) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 删除相册文件并刷新当前列表。 + func delete(file: AlbumFileItem, scenicId: Int?, api: any AssetsServing) async -> Bool { + await mutate(api: api, scenicId: scenicId) { + try await api.deleteAlbumFiles(folderId: folderId, idList: [file.id]) + } + } + + /// 将指定相册文件设为封面。 + func setCover(file: AlbumFileItem, scenicId: Int?, api: any AssetsServing) async -> Bool { + await mutate(api: api, scenicId: scenicId) { + try await api.editAlbumFolder(folderId: folderId, coverFileId: file.id, name: nil, remark: nil) + } + } + + /// 更新相册名称。 + func rename(name: String, scenicId: Int?, api: any AssetsServing) async -> Bool { + let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !folderName.isEmpty else { + errorMessage = "请输入相册名称" + return false + } + return await mutate(api: api, scenicId: scenicId) { + try await api.editAlbumFolder(folderId: folderId, coverFileId: nil, name: folderName, remark: nil) + } + } + + /// 更新相册备注。 + func updateRemark(_ remark: String, scenicId: Int?, api: any AssetsServing) async -> Bool { + await mutate(api: api, scenicId: scenicId) { + try await api.editAlbumFolder(folderId: folderId, coverFileId: nil, name: nil, remark: remark.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + + /// 执行相册变更操作并刷新详情。 + private func mutate(api: any AssetsServing, scenicId: Int?, operation: () async throws -> Void) async -> Bool { + guard !isMutating else { return false } + guard scenicId != nil else { + errorMessage = "缺少当前景区,无法操作相册" + return false + } + isMutating = true + defer { isMutating = false } + + do { + try await operation() + await reload(api: api, scenicId: scenicId) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } +} + +/// 相册预览上传 ViewModel,负责选择相册、本地文件上传和相册入库。 +@MainActor +@Observable +final class AlbumTrailerViewModel { + var folders: [AlbumFolderItem] = [] + var selectedFolderId: Int? + var localFiles: [AlbumLocalUploadFile] = [] + var uploadProgress = 0 + var isLoadingFolders = false + var isSubmitting = false + var errorMessage: String? + var didUploadSuccessfully = false + + /// 加载可上传的相册列表。 + func loadFolders(api: any AssetsServing, scenicId: Int?) async { + guard let scenicId else { + folders = [] + selectedFolderId = nil + errorMessage = "缺少当前景区,无法加载相册" + return + } + + isLoadingFolders = true + errorMessage = nil + defer { isLoadingFolders = false } + + do { + let payload = try await api.albumFolderList( + scenicId: scenicId, + page: 1, + pageSize: 100, + name: "", + startTime: nil, + endTime: nil + ) + folders = payload.list + if selectedFolderId == nil { + selectedFolderId = folders.first?.id + } + } catch { + folders = [] + selectedFolderId = nil + errorMessage = error.localizedDescription + } + } + + /// 添加本地待上传文件。 + func addLocalFiles(_ files: [AlbumLocalUploadFile]) { + localFiles.append(contentsOf: files) + } + + /// 移除一个本地待上传文件。 + func removeLocalFile(id: UUID) { + localFiles.removeAll { $0.id == id } + } + + /// 上传本地文件到 OSS,并登记到指定相册。 + func submit(scenicId: Int?, api: any AssetsServing, uploadService: any OSSUploadServing) async -> Bool { + guard let scenicId else { + errorMessage = "缺少当前景区,无法上传" + return false + } + guard let folderId = selectedFolderId else { + errorMessage = "请选择相册" + return false + } + guard !localFiles.isEmpty else { + errorMessage = "请选择要上传的图片或视频" + return false + } + guard !isSubmitting else { return false } + + isSubmitting = true + didUploadSuccessfully = false + uploadProgress = 0 + defer { isSubmitting = false } + + do { + let fileCount = max(localFiles.count, 1) + for (index, file) in localFiles.enumerated() { + let url = try await uploadService.uploadAlbumFile( + data: file.data, + fileName: file.fileName, + fileType: file.fileType, + scenicId: scenicId + ) { progress in + Task { @MainActor in + let base = Double(index) / Double(fileCount) + let step = Double(progress) / Double(fileCount) + self.uploadProgress = min(99, Int((base + step / 100) * 100)) + } + } + try await api.albumFileUploadURL(scenicId: scenicId, fileURL: url, folderId: folderId) + } + uploadProgress = 100 + didUploadSuccessfully = true + localFiles = [] + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } +} + +private extension String { + /// 空字符串转 nil,方便可选筛选参数传递。 + var assetsNilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/suixinkan/Features/Assets/Views/AlbumViews.swift b/suixinkan/Features/Assets/Views/AlbumViews.swift new file mode 100644 index 0000000..659bb57 --- /dev/null +++ b/suixinkan/Features/Assets/Views/AlbumViews.swift @@ -0,0 +1,704 @@ +// +// AlbumViews.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import AVKit +import PhotosUI +import SwiftUI +import UniformTypeIdentifiers + +/// 相册管理页面,展示相册列表、筛选和新建相册入口。 +struct AlbumListView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(AssetsAPI.self) private var assetsAPI + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading + + @State private var viewModel = AlbumListViewModel() + @State private var showCreateSheet = false + @State private var newAlbumName = "" + @State private var newAlbumRemark = "" + + var body: some View { + ScrollView { + LazyVStack(spacing: AppMetrics.Spacing.medium) { + filterSection + contentSection + } + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("相册管理") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + NavigationLink { + AlbumTrailerEntryView() + } label: { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel("相册预览上传") + + Button { + newAlbumName = "" + newAlbumRemark = "" + showCreateSheet = true + } label: { + Image(systemName: "folder.badge.plus") + } + .accessibilityLabel("新建相册") + } + } + .refreshable { await reload(showLoading: false) } + .task(id: accountContext.currentScenic?.id) { + await reload(showLoading: viewModel.folders.isEmpty) + } + .sheet(isPresented: $showCreateSheet) { + AlbumCreateFolderSheet(name: $newAlbumName, remark: $newAlbumRemark) { + Task { await createAlbum() } + } + .presentationDetents([.medium]) + } + } + + /// 筛选区域,包含搜索和日期输入。 + private var filterSection: some View { + VStack(spacing: AppMetrics.Spacing.small) { + HStack(spacing: AppMetrics.Spacing.small) { + Image(systemName: "magnifyingglass") + .foregroundStyle(AppDesign.placeholder) + TextField("搜索相册名称", text: $viewModel.searchText) + .submitLabel(.search) + .onSubmit { Task { await reload(showLoading: true) } } + Button("搜索") { + Task { await reload(showLoading: true) } + } + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + } + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + + HStack(spacing: AppMetrics.Spacing.small) { + TextField("开始日期 yyyy-MM-dd", text: $viewModel.startTime) + .textInputAutocapitalization(.never) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + TextField("结束日期 yyyy-MM-dd", text: $viewModel.endTime) + .textInputAutocapitalization(.never) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + } + + Button { + Task { await reload(showLoading: true) } + } label: { + Label("应用筛选", systemImage: "line.3.horizontal.decrease.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 相册列表内容区域。 + @ViewBuilder + private var contentSection: some View { + if viewModel.folders.isEmpty { + AssetsEmptyState(title: "暂无相册", message: viewModel.errorMessage ?? "当前景区还没有相册。") + } else { + ForEach(viewModel.folders) { folder in + NavigationLink { + AlbumDetailView(folderId: folder.id, summary: folder) + } label: { + AlbumFolderRow(folder: folder) + } + .buttonStyle(.plain) + .onAppear { + guard folder.id == viewModel.folders.last?.id else { return } + Task { await viewModel.loadMore(api: assetsAPI, scenicId: accountContext.currentScenic?.id) } + } + } + + if viewModel.isLoadingMore { + ProgressView() + .padding(.vertical, AppMetrics.Spacing.small) + } + } + } + + /// 重新加载相册列表。 + private func reload(showLoading: Bool) async { + await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) { + await viewModel.reload(api: assetsAPI, scenicId: accountContext.currentScenic?.id) + } + if let message = viewModel.errorMessage { + toastCenter.show(message) + } + } + + /// 创建相册。 + private func createAlbum() async { + let success = await globalLoading.withLoading { + await viewModel.createFolder( + name: newAlbumName, + remark: newAlbumRemark, + scenicId: accountContext.currentScenic?.id, + api: assetsAPI + ) + } + toastCenter.show(success ? "相册已创建" : (viewModel.errorMessage ?? "创建失败")) + if success { + showCreateSheet = false + } + } +} + +/// 相册详情页面,展示图片/视频列表并支持封面、删除和编辑。 +struct AlbumDetailView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(AssetsAPI.self) private var assetsAPI + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading + + @State private var viewModel: AlbumDetailViewModel + @State private var actionFile: AlbumFileItem? + @State private var previewFile: AlbumFileItem? + @State private var showRenameSheet = false + @State private var showRemarkSheet = false + @State private var showUploadSheet = false + @State private var renameText = "" + @State private var remarkText = "" + + /// 初始化相册详情页面。 + init(folderId: Int, summary: AlbumFolderItem? = nil) { + _viewModel = State(initialValue: AlbumDetailViewModel(folderId: folderId, summary: summary)) + } + + var body: some View { + ScrollView { + LazyVStack(spacing: AppMetrics.Spacing.medium) { + headerSection + tabSection + filesSection + } + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle(viewModel.folder?.name ?? "相册详情") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + showUploadSheet = true + } label: { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel("上传预览") + + Menu { + Button("编辑名称") { + renameText = viewModel.folder?.name ?? "" + showRenameSheet = true + } + Button("编辑备注") { + remarkText = viewModel.folder?.remark ?? "" + showRemarkSheet = true + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .refreshable { await reload(showLoading: false) } + .task(id: accountContext.currentScenic?.id) { + await reload(showLoading: viewModel.files.isEmpty) + } + .confirmationDialog("文件操作", isPresented: actionDialogBinding, presenting: actionFile) { file in + Button("预览") { previewFile = file } + Button("设为封面") { Task { await setCover(file) } } + Button("删除", role: .destructive) { Task { await delete(file) } } + } + .sheet(item: $previewFile) { file in + AlbumPreviewView(file: file) + } + .sheet(isPresented: $showUploadSheet) { + AlbumTrailerUploadView(initialFolderId: viewModel.folderId) { + Task { await reload(showLoading: false) } + } + } + .alert("编辑相册名称", isPresented: $showRenameSheet) { + TextField("相册名称", text: $renameText) + Button("取消", role: .cancel) {} + Button("保存") { Task { await rename() } } + } + .alert("编辑相册备注", isPresented: $showRemarkSheet) { + TextField("备注", text: $remarkText) + Button("取消", role: .cancel) {} + Button("保存") { Task { await updateRemark() } } + } + } + + /// 相册头部信息。 + private var headerSection: some View { + HStack(spacing: AppMetrics.Spacing.medium) { + RemoteImage(urlString: viewModel.folder?.coverURLString ?? "", contentMode: .fill) { + Image(systemName: "photo.on.rectangle") + .font(.system(size: 32, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppDesign.primarySoft) + } + .frame(width: 92, height: 92) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + Text(viewModel.folder?.name ?? "相册") + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + .lineLimit(2) + Text("图片 \(viewModel.folder?.countImage ?? 0) · 视频 \(viewModel.folder?.countVideo ?? 0)") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + Text(viewModel.folder?.remark.assetsDisplayText ?? "暂无备注") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + .lineLimit(2) + } + Spacer() + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 图片/视频分段切换。 + private var tabSection: some View { + Picker("文件类型", selection: $viewModel.selectedTab) { + ForEach(AlbumFileTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .onChange(of: viewModel.selectedTab) { _, tab in + Task { await viewModel.selectTab(tab, api: assetsAPI, scenicId: accountContext.currentScenic?.id) } + } + } + + /// 文件网格内容区域。 + @ViewBuilder + private var filesSection: some View { + if viewModel.files.isEmpty { + AssetsEmptyState(title: "暂无\(viewModel.selectedTab.title)", message: viewModel.errorMessage ?? "这个相册还没有\(viewModel.selectedTab.title)文件。") + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: AppMetrics.Spacing.medium)], spacing: AppMetrics.Spacing.medium) { + ForEach(viewModel.files) { file in + AlbumFileCard(file: file) { + previewFile = file + } moreAction: { + actionFile = file + } + .onAppear { + guard file.id == viewModel.files.last?.id else { return } + Task { await viewModel.loadMore(api: assetsAPI, scenicId: accountContext.currentScenic?.id) } + } + } + } + + if viewModel.isLoadingMore { + ProgressView() + .padding(.vertical, AppMetrics.Spacing.small) + } + } + } + + /// 文件操作弹窗绑定。 + private var actionDialogBinding: Binding { + Binding( + get: { actionFile != nil }, + set: { isPresented in + if !isPresented { actionFile = nil } + } + ) + } + + /// 重新加载相册详情。 + private func reload(showLoading: Bool) async { + await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) { + await viewModel.reload(api: assetsAPI, scenicId: accountContext.currentScenic?.id) + } + if let message = viewModel.errorMessage { + toastCenter.show(message) + } + } + + /// 设置文件为相册封面。 + private func setCover(_ file: AlbumFileItem) async { + let success = await globalLoading.withLoading { + await viewModel.setCover(file: file, scenicId: accountContext.currentScenic?.id, api: assetsAPI) + } + toastCenter.show(success ? "已设为封面" : (viewModel.errorMessage ?? "设置失败")) + } + + /// 删除相册文件。 + private func delete(_ file: AlbumFileItem) async { + let success = await globalLoading.withLoading { + await viewModel.delete(file: file, scenicId: accountContext.currentScenic?.id, api: assetsAPI) + } + toastCenter.show(success ? "已删除" : (viewModel.errorMessage ?? "删除失败")) + } + + /// 保存相册名称。 + private func rename() async { + let success = await globalLoading.withLoading { + await viewModel.rename(name: renameText, scenicId: accountContext.currentScenic?.id, api: assetsAPI) + } + toastCenter.show(success ? "已保存" : (viewModel.errorMessage ?? "保存失败")) + } + + /// 保存相册备注。 + private func updateRemark() async { + let success = await globalLoading.withLoading { + await viewModel.updateRemark(remarkText, scenicId: accountContext.currentScenic?.id, api: assetsAPI) + } + toastCenter.show(success ? "已保存" : (viewModel.errorMessage ?? "保存失败")) + } +} + +/// 相册预览上传入口页面,用于从首页选择相册并上传文件。 +struct AlbumTrailerEntryView: View { + var body: some View { + AlbumTrailerUploadView(initialFolderId: nil, onUploaded: nil) + .navigationTitle("相册预览上传") + .navigationBarTitleDisplayMode(.inline) + } +} + +/// 相册预览上传页面,负责选择相册、本地文件和提交上传。 +struct AlbumTrailerUploadView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(AssetsAPI.self) private var assetsAPI + @Environment(OSSUploadService.self) private var uploadService + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.dismiss) private var dismiss + @Environment(\.globalLoading) private var globalLoading + + @State private var viewModel = AlbumTrailerViewModel() + @State private var selectedItems: [PhotosPickerItem] = [] + + let initialFolderId: Int? + let onUploaded: (() -> Void)? + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + folderPickerSection + filePickerSection + selectedFilesSection + submitButton + } + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .task(id: accountContext.currentScenic?.id) { + await viewModel.loadFolders(api: assetsAPI, scenicId: accountContext.currentScenic?.id) + if let initialFolderId { + viewModel.selectedFolderId = initialFolderId + } + if let message = viewModel.errorMessage { + toastCenter.show(message) + } + } + .onChange(of: selectedItems) { _, items in + Task { await loadPickedFiles(items) } + } + } + + /// 相册选择区域。 + private var folderPickerSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Text("选择相册") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + + Picker("选择相册", selection: Binding( + get: { viewModel.selectedFolderId ?? 0 }, + set: { viewModel.selectedFolderId = $0 == 0 ? nil : $0 } + )) { + Text("请选择相册").tag(0) + ForEach(viewModel.folders) { folder in + Text(folder.name).tag(folder.id) + } + } + .pickerStyle(.menu) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppMetrics.Spacing.small) + .background(Color(hex: 0xF5F7FA), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 本地文件选择区域。 + private var filePickerSection: some View { + PhotosPicker(selection: $selectedItems, maxSelectionCount: 20, matching: .any(of: [.images, .videos])) { + Label("选择图片或视频", systemImage: "photo.badge.plus") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .buttonStyle(.borderedProminent) + } + + /// 已选本地文件列表。 + @ViewBuilder + private var selectedFilesSection: some View { + if viewModel.localFiles.isEmpty { + AssetsEmptyState(title: "还未选择文件", message: "可一次选择最多 20 个图片或视频上传到相册。") + } else { + VStack(spacing: AppMetrics.Spacing.small) { + ForEach(viewModel.localFiles) { file in + HStack { + Image(systemName: file.fileType == 1 ? "play.rectangle.fill" : "photo.fill") + .foregroundStyle(AppDesign.primary) + Text(file.fileName) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + .lineLimit(1) + Spacer() + Button { + viewModel.removeLocalFile(id: file.id) + } label: { + Image(systemName: "xmark.circle.fill") + .frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea) + } + .foregroundStyle(AppDesign.placeholder) + } + } + + if viewModel.isSubmitting { + ProgressView(value: Double(viewModel.uploadProgress), total: 100) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + } + + /// 上传提交按钮。 + private var submitButton: some View { + Button { + Task { await submit() } + } label: { + Text(viewModel.isSubmitting ? "上传中..." : "开始上传") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, AppMetrics.Spacing.small) + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.isSubmitting) + } + + /// 读取 PhotosPicker 选择结果。 + private func loadPickedFiles(_ items: [PhotosPickerItem]) async { + guard !items.isEmpty else { return } + defer { selectedItems = [] } + let files = await AssetPickerLoader.loadAlbumFiles(from: items) + viewModel.addLocalFiles(files) + } + + /// 提交相册预览上传。 + private func submit() async { + let success = await globalLoading.withLoading { + await viewModel.submit( + scenicId: accountContext.currentScenic?.id, + api: assetsAPI, + uploadService: uploadService + ) + } + toastCenter.show(success ? "上传完成" : (viewModel.errorMessage ?? "上传失败")) + if success { + onUploaded?() + dismiss() + } + } +} + +/// 新建相册表单弹窗。 +private struct AlbumCreateFolderSheet: View { + @Binding var name: String + @Binding var remark: String + let submitAction: () -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + TextField("相册名称", text: $name) + TextField("备注", text: $remark, axis: .vertical) + .lineLimit(3, reservesSpace: true) + } + .navigationTitle("新建相册") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("取消") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button("创建", action: submitAction) + } + } + } + } +} + +/// 相册文件夹行,展示封面、名称、数量和备注。 +private struct AlbumFolderRow: View { + let folder: AlbumFolderItem + + var body: some View { + HStack(spacing: AppMetrics.Spacing.medium) { + RemoteImage(urlString: folder.coverURLString, contentMode: .fill) { + Image(systemName: "photo.on.rectangle") + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppDesign.primarySoft) + } + .frame(width: 72, height: 72) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) { + Text(folder.name.assetsDisplayText) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + .lineLimit(2) + Text("图片 \(folder.countImage) · 视频 \(folder.countVideo)") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + Text(folder.remark.assetsDisplayText) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + .lineLimit(1) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(AppDesign.placeholder) + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } +} + +/// 相册文件卡片,展示缩略图和操作按钮。 +private struct AlbumFileCard: View { + let file: AlbumFileItem + let openAction: () -> Void + let moreAction: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + Button(action: openAction) { + ZStack(alignment: .center) { + RemoteImage(urlString: file.previewURLString, contentMode: .fill) { + Image(systemName: file.isVideo ? "play.rectangle.fill" : "photo") + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppDesign.primarySoft) + } + if file.isVideo { + Image(systemName: "play.circle.fill") + .font(.system(size: 36, weight: .semibold)) + .foregroundStyle(.white) + .shadow(radius: 4) + } + } + .aspectRatio(1.2, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + } + .buttonStyle(.plain) + + HStack(spacing: AppMetrics.Spacing.xSmall) { + VStack(alignment: .leading, spacing: 2) { + Text(file.fileName.assetsDisplayText) + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + .lineLimit(1) + Text(file.displayFileSize) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + Spacer() + Button(action: moreAction) { + Image(systemName: "ellipsis") + .frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea) + } + .buttonStyle(.plain) + .foregroundStyle(AppDesign.textSecondary) + } + } + .padding(AppMetrics.Spacing.small) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } +} + +/// 相册文件预览页,支持图片和视频。 +struct AlbumPreviewView: View { + let file: AlbumFileItem + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Group { + if file.isVideo, let url = URL(string: file.fileUrl) { + VideoPlayer(player: AVPlayer(url: url)) + } else { + RemoteImage(urlString: file.previewURLString, contentMode: .fit) { + AssetsEmptyState(title: "无法预览", message: file.fileName) + } + .padding() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(file.isVideo ? 1 : 0.04)) + .navigationTitle(file.fileName.assetsDisplayText) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("关闭") { dismiss() } + } + } + } + } +} + +extension AssetPickerLoader { + /// 加载相册预览上传本地文件。 + @MainActor + static func loadAlbumFiles(from items: [PhotosPickerItem]) async -> [AlbumLocalUploadFile] { + var result: [AlbumLocalUploadFile] = [] + for item in items { + guard let data = try? await item.loadTransferable(type: Data.self) else { continue } + let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) } + let ext = isVideo ? "mp4" : "jpg" + let fileName = "album_\(UUID().uuidString).\(ext)" + result.append(AlbumLocalUploadFile(data: data, fileName: fileName, fileType: isVideo ? 1 : 2)) + } + return result + } +} + +private extension String { + /// 返回非空展示文本。 + var assetsDisplayText: String { + let text = trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? "--" : text + } +} diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index c66e893..bb51f5c 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` 已由 `Features/Assets` 接管。`album_list`、`album_trailer`、`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` 已由 `Features/Assets` 接管。`sample_management`、`sample_upload` 后续按样片模块独立迁移。`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 71c2a10..fec95eb 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 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 4c00470..e48614a 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 "album_list": + return .destination(.albumList) + case "album_trailer": + return .destination(.albumTrailer) case "store", "more_functions": return .destination(.moreFunctions) case "fly", "pilot_controller": @@ -123,8 +127,6 @@ enum HomeMenuRouter { "pm", "pm_manager", "project_edit", - "album_list", - "album_trailer", "sample_management", "sample_upload", "live_stream_management", @@ -150,6 +152,71 @@ enum HomeMenuRouter { titleMap[uri] ?? uri } + #if DEBUG + /// 返回调试页使用的全部已知首页菜单,不读取权限,便于预览迁移页面。 + static func debugAllMenuItems() -> [HomeMenuItem] { + let preferredOrder = [ + "space_settings", + "basic_info", + "album_list", + "album_trailer", + "wallet", + "payment_collection", + "payment_qr", + "payment_code", + "cloud_management", + "cloud_storage_transit", + "asset_management", + "material_upload", + "task_management", + "task_management_editor", + "task_create", + "schedule_management", + "system_settings", + "message_center", + "checkin_points", + "sample_management", + "sample_upload", + "live_stream_management", + "live_album", + "scenicselection", + "scenicapplication", + "permission_apply", + "permission_apply_status", + "scenic_settlement", + "scenic_settlement_review", + "verification_order", + "photographer_orders", + "/scenic-order-manage", + "photographer_stats", + "pm", + "pm_manager", + "project_edit", + "location_report", + "registration_invitation", + "photographer_invite", + "invite_record", + "store", + "fly", + "pilot_cert", + "pilot_controller", + "/scenic-queue", + "queue_management", + "operating-area", + "deposit_order_detail", + "deposit_order", + "deposit_order_shooting_info", + "withdrawal_audit", + "location_report_history", + "more_functions" + ] + let orderedURIs = preferredOrder + titleMap.keys.sorted().filter { !preferredOrder.contains($0) } + return orderedURIs.map { uri in + HomeMenuItem(title: title(for: uri), uri: uri, iconSrc: nil) + } + } + #endif + /// 在可用 URI 集合中选取规范形式。 static func canonicalURI(for uri: String, availableURIs: Set) -> String { if availableURIs.contains(uri) { diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index 20b88a8..17a239d 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 .albumList: + AlbumListView() + case .albumTrailer: + AlbumTrailerEntryView() case let .modulePlaceholder(uri, title): HomeMigrationModuleView(title: title, uri: uri) } diff --git a/suixinkan/Features/Profile/Profile.md b/suixinkan/Features/Profile/Profile.md index 73e5a2c..f26b287 100644 --- a/suixinkan/Features/Profile/Profile.md +++ b/suixinkan/Features/Profile/Profile.md @@ -17,6 +17,8 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括 登录态清理和缓存清理由 App 模块的 `AuthSessionCoordinator` 统一处理。 +DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,进入 `DebugHomeMenuPreviewView` 后可查看并跳转所有已知首页菜单,不读取角色权限。该入口只用于迁移预览和调试,不进入 Release 包。 + ## 核心对象 - `ProfileView`:个人信息页 UI,负责展示资料、编辑入口、密码弹窗和退出确认。 @@ -25,6 +27,7 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括 - `AccountSwitchView` / `AccountSwitchViewModel`:账号切换页面和状态逻辑。 - `RealNameAuthView` / `RealNameAuthViewModel`:实名认证页面和表单逻辑。 - `SettingsCenterView` / `AgreementView`:设置中心和协议 H5 页面。 +- `DebugHomeMenuPreviewView`:DEBUG 专用首页菜单预览页,不参与正式业务流程。 - `ProfileAPI`:封装用户资料、实名认证和资料更新接口。 - `OSSUploadService`:上传头像和实名认证证件图片,返回可提交给业务接口的 OSS URL。 - `UserInfoResponse`:用户基础资料。 diff --git a/suixinkan/Features/Profile/Routing/ProfileRoute.swift b/suixinkan/Features/Profile/Routing/ProfileRoute.swift index 9f42e33..abd27ac 100644 --- a/suixinkan/Features/Profile/Routing/ProfileRoute.swift +++ b/suixinkan/Features/Profile/Routing/ProfileRoute.swift @@ -13,6 +13,9 @@ enum ProfileRoute: Hashable { case realNameAuth case settings case agreement(AgreementPage) + #if DEBUG + case debugHomeMenus + #endif /// 构建个人中心路由对应的 SwiftUI 目标页面。 @ViewBuilder @@ -26,6 +29,10 @@ enum ProfileRoute: Hashable { SettingsCenterView() case .agreement(let page): AgreementView(page: page) + #if DEBUG + case .debugHomeMenus: + DebugHomeMenuPreviewView() + #endif } } } diff --git a/suixinkan/Features/Profile/Views/DebugHomeMenuPreviewView.swift b/suixinkan/Features/Profile/Views/DebugHomeMenuPreviewView.swift new file mode 100644 index 0000000..7aeca51 --- /dev/null +++ b/suixinkan/Features/Profile/Views/DebugHomeMenuPreviewView.swift @@ -0,0 +1,148 @@ +// +// DebugHomeMenuPreviewView.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +#if DEBUG +import SwiftUI + +/// 首页菜单调试预览页,展示所有已知菜单入口并跳过权限过滤。 +struct DebugHomeMenuPreviewView: View { + @Environment(AppRouter.self) private var appRouter + @Environment(RouterPath.self) private var router + @Environment(ToastCenter.self) private var toastCenter + + private let items = HomeMenuRouter.debugAllMenuItems() + + var body: some View { + List { + Section { + ForEach(items) { item in + Button { + open(item) + } label: { + DebugHomeMenuRow(item: item, route: HomeMenuRouter.resolve(uri: item.uri, title: item.title)) + } + .buttonStyle(.plain) + } + } header: { + Text("全部首页菜单") + } footer: { + Text("DEBUG 调试入口不读取角色权限,仅用于预览页面迁移状态。") + } + } + .listStyle(.insetGrouped) + .navigationTitle("首页菜单调试") + .navigationBarTitleDisplayMode(.inline) + } + + /// 打开指定调试菜单。 + private func open(_ item: HomeMenuItem) { + let route = HomeMenuRouter.resolve(uri: item.uri, title: item.title) + switch route { + case .tab(let tab): + appRouter.select(tab) + case .orders(let entry): + appRouter.selectOrders(entry: entry) + case .destination(let homeRoute): + router.navigate(to: .home(homeRoute)) + case .unsupported(_, let title, let reason): + toastCenter.show(reason) + router.navigate(to: .home(.modulePlaceholder(uri: item.uri, title: title))) + case .placeholder(let uri, let title): + HomeRouteDiagnostics.recordUnknown(uri: uri, title: title) + router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title))) + } + } +} + +/// 首页菜单调试行,展示菜单标题、URI 和解析状态。 +private struct DebugHomeMenuRow: View { + let item: HomeMenuItem + let route: HomeMenuResolvedRoute + + var body: some View { + HStack(spacing: AppMetrics.Spacing.small) { + Image(systemName: iconName) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(iconColor) + .frame(width: 30, height: 30) + .background(iconColor.opacity(0.12), in: Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Text(item.uri) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + .lineLimit(1) + } + + Spacer() + + Text(routeLabel) + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(iconColor) + + Image(systemName: "chevron.right") + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(AppDesign.placeholder) + } + .contentShape(Rectangle()) + .padding(.vertical, AppMetrics.Spacing.xSmall) + } + + /// 返回当前路由类型展示文案。 + private var routeLabel: String { + switch route { + case .tab: + return "Tab" + case .orders: + return "订单" + case .destination(let homeRoute): + if case .modulePlaceholder = homeRoute { + return "占位" + } + return "页面" + case .unsupported: + return "不支持" + case .placeholder: + return "未知" + } + } + + /// 返回当前路由类型图标。 + private var iconName: String { + switch route { + case .tab: + return "rectangle.grid.1x2" + case .orders: + return "doc.text" + case .destination(let homeRoute): + if case .modulePlaceholder = homeRoute { + return "square.dashed" + } + return "arrowshape.turn.up.right" + case .unsupported: + return "nosign" + case .placeholder: + return "questionmark.circle" + } + } + + /// 返回当前路由类型颜色。 + private var iconColor: Color { + switch route { + case .tab, .orders, .destination: + AppDesign.primary + case .unsupported: + .red + case .placeholder: + AppDesign.warning + } + } +} +#endif diff --git a/suixinkan/Features/Profile/Views/ProfileView.swift b/suixinkan/Features/Profile/Views/ProfileView.swift index dc79b3d..b37eb96 100644 --- a/suixinkan/Features/Profile/Views/ProfileView.swift +++ b/suixinkan/Features/Profile/Views/ProfileView.swift @@ -322,6 +322,20 @@ struct ProfileView: View { } } .buttonStyle(.plain) + + #if DEBUG + divider + + Button { + router.navigate(to: .profile(.debugHomeMenus)) + } label: { + infoRow(title: "首页调试") { + statusBadge(text: "DEBUG", icon: "hammer.fill", foreground: AppDesign.primary, background: AppDesign.primarySoft) + rowChevron + } + } + .buttonStyle(.plain) + #endif } .padding(.horizontal, 24) .padding(.vertical, 4) diff --git a/suixinkanTests/Assets/AssetsAPITests.swift b/suixinkanTests/Assets/AssetsAPITests.swift index 0f302b5..f0fb8de 100644 --- a/suixinkanTests/Assets/AssetsAPITests.swift +++ b/suixinkanTests/Assets/AssetsAPITests.swift @@ -126,6 +126,67 @@ final class AssetsAPITests: XCTestCase { XCTAssertEqual((body["media_list"] as? [[String: Any]])?.first?["oss_url"] as? String, "https://cdn/a.jpg") } + /// 测试相册接口使用旧工程路径、query 和请求体。 + func testAlbumRequestsUseExpectedPathQueryAndBodies() async throws { + let session = AssetsRecordingURLSession(responses: [ + Self.albumFolderListResponse, + Self.albumFolderInfoResponse, + Self.albumFileListResponse, + Self.emptyResponse, + Self.emptyResponse, + Self.emptyResponse, + Self.emptyResponse + ]) + let api = AssetsAPI(client: APIClient(session: session)) + + let folders = try await api.albumFolderList( + scenicId: 9, + page: 0, + pageSize: 0, + name: "旅拍", + startTime: "2026-06-01", + endTime: "2026-06-24" + ) + let info = try await api.albumFolderInfo(id: 3) + let files = try await api.albumFileList(scenicId: 9, folderId: 3, fileType: 2, page: 0, pageSize: 0) + try await api.addAlbumFolder(scenicId: 9, name: "新相册", remark: "备注") + try await api.editAlbumFolder(folderId: 3, coverFileId: 11, name: "改名", remark: nil) + try await api.deleteAlbumFiles(folderId: 3, idList: [11, 12]) + try await api.albumFileUploadURL(scenicId: 9, fileURL: "https://cdn/a.jpg", folderId: 3) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/album/folder-list", + "/api/yf-handset-app/photog/album/folder-info", + "/api/yf-handset-app/photog/album/file-list", + "/api/yf-handset-app/photog/album/folder-add", + "/api/yf-handset-app/photog/album/folder-edit", + "/api/yf-handset-app/photog/album/file-delete", + "/api/yf-handset-app/photog/album/file-upload-url" + ]) + let folderQuery = assetsQueryItems(from: session.requests[0]) + XCTAssertEqual(folderQuery["scenic_id"], "9") + XCTAssertEqual(folderQuery["page"], "1") + XCTAssertEqual(folderQuery["page_size"], "1") + XCTAssertEqual(folderQuery["cloud_folder_type"], "1") + XCTAssertEqual(folderQuery["name"], "旅拍") + XCTAssertEqual(folderQuery["start_time"], "2026-06-01") + XCTAssertEqual(folderQuery["end_time"], "2026-06-24") + XCTAssertEqual(folders.list.first?.countImage, 4) + XCTAssertEqual(info.coverFile?.fileSize, 2048) + XCTAssertTrue(files.list.first?.isImage == true) + + let addBody = try bodyObject(from: session.requests[3]) + XCTAssertEqual(addBody["scenic_id"] as? String, "9") + XCTAssertEqual(addBody["cloud_folder_type"] as? Int, 1) + let editBody = try bodyObject(from: session.requests[4]) + XCTAssertEqual(editBody["cover_file_id"] as? Int, 11) + let deleteBody = try bodyObject(from: session.requests[5]) + XCTAssertEqual(deleteBody["folder_id"] as? Int, 3) + XCTAssertEqual(deleteBody["id_list"] as? [Int], [11, 12]) + let uploadQuery = assetsQueryItems(from: session.requests[6]) + XCTAssertEqual(uploadQuery["file_url"], "https://cdn/a.jpg") + } + fileprivate static let emptyResponse = #"{"code":100000,"msg":"success","data":{}}"#.data(using: .utf8)! fileprivate static let permissionResponse = #"{"code":100000,"msg":"success","data":{"can_upload":"0","reason":"空间不足"}}"#.data(using: .utf8)! fileprivate static let cloudListResponse = """ @@ -144,6 +205,19 @@ 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 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":"备注"} + ]}} + """.data(using: .utf8)! + fileprivate static let albumFolderInfoResponse = """ + {"code":100000,"msg":"success","data":{"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":"备注"}} + """.data(using: .utf8)! + fileprivate static let albumFileListResponse = """ + {"code":100000,"msg":"success","data":{"total":"1","list":[ + {"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":""} + ]}} + """.data(using: .utf8)! } /// 资产 API 测试用 URLSession,记录请求并返回固定响应。 diff --git a/suixinkanTests/Assets/AssetsViewModelTests.swift b/suixinkanTests/Assets/AssetsViewModelTests.swift index 6097162..77682b0 100644 --- a/suixinkanTests/Assets/AssetsViewModelTests.swift +++ b/suixinkanTests/Assets/AssetsViewModelTests.swift @@ -144,6 +144,121 @@ final class AssetsViewModelTests: XCTestCase { XCTAssertFalse(success) XCTAssertEqual(api.mediaUploadRequests.count, 0) } + + /// 测试相册列表无景区时清空数据且不请求接口。 + func testAlbumListClearsWithoutScenic() async { + let api = MockAssetsService() + let viewModel = AlbumListViewModel() + + await viewModel.reload(api: api, scenicId: nil) + + XCTAssertTrue(viewModel.folders.isEmpty) + XCTAssertEqual(api.albumFolderListRequests.count, 0) + XCTAssertEqual(viewModel.errorMessage, "缺少当前景区,无法加载相册") + } + + /// 测试相册列表搜索、日期筛选和分页参数。 + func testAlbumListFilterAndPagination() async { + let api = MockAssetsService() + api.albumFolderPages = [ + ListPayload(total: 2, list: [AlbumFolderItem.fixture(id: 1, name: "旅拍")]), + ListPayload(total: 2, list: [AlbumFolderItem.fixture(id: 2, name: "亲子")]) + ] + let viewModel = AlbumListViewModel() + viewModel.searchText = " 旅拍 " + viewModel.startTime = "2026-06-01" + viewModel.endTime = "2026-06-24" + + await viewModel.reload(api: api, scenicId: 9) + await viewModel.loadMore(api: api, scenicId: 9) + + XCTAssertEqual(api.albumFolderListRequests.first?.name, "旅拍") + XCTAssertEqual(api.albumFolderListRequests.first?.startTime, "2026-06-01") + XCTAssertEqual(api.albumFolderListRequests.last?.page, 2) + XCTAssertEqual(viewModel.folders.map(\.id), [1, 2]) + } + + /// 测试新建相册成功后刷新列表。 + func testAlbumCreateRefreshesList() async { + let api = MockAssetsService() + api.albumFolderPages = [ListPayload(total: 1, list: [AlbumFolderItem.fixture(id: 3, name: "新相册")])] + let viewModel = AlbumListViewModel() + + let success = await viewModel.createFolder(name: " 新相册 ", remark: " 备注 ", scenicId: 9, api: api) + + XCTAssertTrue(success) + XCTAssertEqual(api.albumAddRequests.first?.name, "新相册") + XCTAssertEqual(api.albumAddRequests.first?.remark, "备注") + XCTAssertEqual(viewModel.folders.first?.id, 3) + } + + /// 测试相册详情加载信息和文件列表,切换 Tab 会重置分页。 + func testAlbumDetailLoadsInfoAndSwitchesTab() async { + let api = MockAssetsService() + api.albumFilePages = [ + ListPayload(total: 1, list: [AlbumFileItem.fixture(id: 11, fileType: 2)]), + ListPayload(total: 1, list: [AlbumFileItem.fixture(id: 12, fileType: 1)]) + ] + let viewModel = AlbumDetailViewModel(folderId: 3) + + await viewModel.reload(api: api, scenicId: 9) + await viewModel.selectTab(.video, api: api, scenicId: 9) + + XCTAssertEqual(api.albumInfoRequests, [3, 3]) + XCTAssertEqual(api.albumFileListRequests.map { $0.fileType }, [2, 1]) + XCTAssertEqual(viewModel.files.first?.id, 12) + XCTAssertEqual(viewModel.page, 1) + } + + /// 测试相册详情删除、设置封面和编辑信息会调用正确接口。 + func testAlbumDetailMutationsUseExpectedRequests() async { + let api = MockAssetsService() + let viewModel = AlbumDetailViewModel(folderId: 3) + let file = AlbumFileItem.fixture(id: 11, fileType: 2) + + _ = await viewModel.delete(file: file, scenicId: 9, api: api) + _ = await viewModel.setCover(file: file, scenicId: 9, api: api) + _ = await viewModel.rename(name: " 改名 ", scenicId: 9, api: api) + _ = await viewModel.updateRemark(" 新备注 ", scenicId: 9, api: api) + + XCTAssertEqual(api.albumDeleteRequests.first?.idList, [11]) + XCTAssertEqual(api.albumEditRequests[0].coverFileId, 11) + XCTAssertEqual(api.albumEditRequests[1].name, "改名") + XCTAssertEqual(api.albumEditRequests[2].remark, "新备注") + } + + /// 测试相册预览上传会先上传 OSS,再登记相册文件。 + func testAlbumTrailerUploadUploadsOSSBeforeRegisteringFile() async { + let api = MockAssetsService() + api.albumFolderPages = [ListPayload(total: 1, list: [AlbumFolderItem.fixture(id: 3, name: "旅拍")])] + let uploader = MockAssetsUploader() + let viewModel = AlbumTrailerViewModel() + viewModel.addLocalFiles([AlbumLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 2)]) + await viewModel.loadFolders(api: api, scenicId: 9) + + let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader) + + XCTAssertTrue(success) + XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["a.jpg"]) + XCTAssertEqual(api.albumUploadURLRequests.first?.fileURL, "https://cdn.example.com/a.jpg") + XCTAssertTrue(viewModel.localFiles.isEmpty) + XCTAssertEqual(viewModel.uploadProgress, 100) + } + + /// 测试相册上传失败时不会登记文件入库。 + func testAlbumTrailerUploadFailureDoesNotRegisterFile() async { + let api = MockAssetsService() + let uploader = MockAssetsUploader() + uploader.shouldFail = true + let viewModel = AlbumTrailerViewModel() + viewModel.selectedFolderId = 3 + viewModel.addLocalFiles([AlbumLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 2)]) + + let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader) + + XCTAssertFalse(success) + XCTAssertEqual(api.albumUploadURLRequests.count, 0) + } } /// 资产服务测试替身,记录请求并返回预设数据。 @@ -164,8 +279,25 @@ private final class MockAssetsService: AssetsServing { let page: Int } + struct AlbumFolderListRequest: Equatable { + let scenicId: Int + let page: Int + let pageSize: Int + let name: String + let startTime: String? + let endTime: String? + } + + struct AlbumUploadURLRequest: Equatable { + let scenicId: Int + let fileURL: String + let folderId: Int + } + var cloudPages: [ListPayload] = [ListPayload(total: 0, list: [])] var mediaListResponses: [MediaLibraryListResponse] = [MediaLibraryListResponse.fixture(total: 0, ids: [])] + var albumFolderPages: [ListPayload] = [ListPayload(total: 0, list: [])] + var albumFilePages: [ListPayload] = [ListPayload(total: 0, list: [])] var uploadPermission = true var uploadReason = "" var cloudListRequests: [CloudListRequest] = [] @@ -174,6 +306,13 @@ private final class MockAssetsService: AssetsServing { var mediaOperationRequests: [MediaAlbumOperationRequest] = [] var mediaUploadRequests: [MediaAlbumUploadRequest] = [] var mediaEditRequests: [MediaAlbumEditRequest] = [] + var albumFolderListRequests: [AlbumFolderListRequest] = [] + var albumInfoRequests: [Int] = [] + var albumFileListRequests: [(scenicId: Int, folderId: Int, fileType: Int, page: Int)] = [] + var albumAddRequests: [(scenicId: Int, name: String, remark: String)] = [] + var albumEditRequests: [AlbumFolderEditRequest] = [] + var albumDeleteRequests: [AlbumFileDeleteRequest] = [] + var albumUploadURLRequests: [AlbumUploadURLRequest] = [] /// 返回云盘文件列表。 func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> ListPayload { @@ -237,6 +376,44 @@ private final class MockAssetsService: AssetsServing { func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws { mediaEditRequests.append(request) } + + /// 返回相册文件夹列表。 + 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)) + return albumFolderPages.isEmpty ? ListPayload(total: 0, list: []) : albumFolderPages.removeFirst() + } + + /// 返回相册文件夹详情。 + func albumFolderInfo(id: Int) async throws -> AlbumFolderItem { + albumInfoRequests.append(id) + return AlbumFolderItem.fixture(id: id, name: "相册\(id)") + } + + /// 返回相册文件列表。 + func albumFileList(scenicId: Int, folderId: Int, fileType: Int, page: Int, pageSize: Int) async throws -> ListPayload { + albumFileListRequests.append((scenicId: scenicId, folderId: folderId, fileType: fileType, page: page)) + return albumFilePages.isEmpty ? ListPayload(total: 0, list: []) : albumFilePages.removeFirst() + } + + /// 新建相册。 + func addAlbumFolder(scenicId: Int, name: String, remark: String) async throws { + albumAddRequests.append((scenicId: scenicId, name: name, remark: remark)) + } + + /// 编辑相册。 + func editAlbumFolder(folderId: Int, coverFileId: Int?, name: String?, remark: String?) async throws { + albumEditRequests.append(AlbumFolderEditRequest(folderId: folderId, coverFileId: coverFileId, name: name, remark: remark)) + } + + /// 删除相册文件。 + func deleteAlbumFiles(folderId: Int, idList: [Int]) async throws { + albumDeleteRequests.append(AlbumFileDeleteRequest(idList: idList, folderId: folderId)) + } + + /// 登记相册上传文件。 + func albumFileUploadURL(scenicId: Int, fileURL: String, folderId: Int) async throws { + albumUploadURLRequests.append(AlbumUploadURLRequest(scenicId: scenicId, fileURL: fileURL, folderId: folderId)) + } } /// OSS 上传测试替身,记录上传文件并返回固定 CDN URL。 @@ -362,3 +539,39 @@ private extension MediaLibraryDetail { return try! JSONDecoder().decode(MediaLibraryDetail.self, from: data) } } + +private extension AlbumFolderItem { + /// 创建相册文件夹测试数据。 + static func fixture(id: Int, name: String) -> AlbumFolderItem { + let payload: [String: Any] = [ + "id": id, + "name": name, + "cover_file_id": 0, + "count_video": 1, + "count_image": 2, + "created_at": "2026", + "remark": "备注" + ] + let data = try! JSONSerialization.data(withJSONObject: payload) + return try! JSONDecoder().decode(AlbumFolderItem.self, from: data) + } +} + +private extension AlbumFileItem { + /// 创建相册文件测试数据。 + static func fixture(id: Int, fileType: Int) -> AlbumFileItem { + let fileName = fileType == 1 ? "v.mp4" : "a.jpg" + let payload: [String: Any] = [ + "id": id, + "file_name": fileName, + "file_type": fileType, + "file_url": "https://cdn.example.com/\(fileName)", + "cover_url": "", + "file_size": "2048", + "file_size_human": "2KB", + "remark": "" + ] + let data = try! JSONSerialization.data(withJSONObject: payload) + return try! JSONDecoder().decode(AlbumFileItem.self, from: data) + } +} diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index c3f4ea1..1479abf 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -39,6 +39,8 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "cloud_storage_transit", title: ""), .destination(.cloudStorageTransit)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "asset_management", title: ""), .destination(.materialLibrary)) 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)) } /// 测试已知但未迁移的首页路由会进入安全占位。 @@ -51,10 +53,6 @@ final class HomeMenuRouterTests: XCTestCase { HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""), .destination(.modulePlaceholder(uri: "scenic_settlement_review", title: "结算审核")) ) - XCTAssertEqual( - HomeMenuRouter.resolve(uri: "album_list", title: ""), - .destination(.modulePlaceholder(uri: "album_list", title: "相册管理")) - ) XCTAssertEqual( HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.modulePlaceholder(uri: "sample_upload", title: "上传样片")) @@ -159,6 +157,23 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertTrue(audit.hasUnknownRoutes) } + #if DEBUG + /// 测试 DEBUG 菜单列表不依赖权限,包含已迁移和未迁移入口。 + func testDebugAllMenuItemsExposeKnownMenusWithoutPermissions() { + let items = HomeMenuRouter.debugAllMenuItems() + let uris = items.map(\.uri) + + XCTAssertTrue(uris.contains("album_list")) + 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: "样片管理")) + ) + } + #endif + /// 创建角色权限测试实体。 private func rolePermission(roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse { RolePermissionResponse( diff --git a/功能同步Checklist.md b/功能同步Checklist.md index 7cfab20..1267d79 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -1,6 +1,6 @@ # 功能同步 Checklist -更新时间:2026-06-23 +更新时间:2026-06-24 ## 状态说明 @@ -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。 | @@ -88,14 +88,15 @@ | 部分完成 | 排队管理 | `/scenic-queue` / `queue_management` 已识别,进入占位页。 | 迁移队列列表、叫号、设置等流程。 | | 完成 | 立即收款/收款码 | 收款码、设置金额、动态二维码、保存二维码、收款记录、到账轮询、语音提醒。 | 后续可按业务继续优化收款反馈细节。 | | 部分完成 | 押金订单 | 入口已识别,进入占位页。 | 迁移押金订单详情和拍摄信息。 | -| 部分完成 | 任务管理 | 入口已识别,进入占位页。 | 迁移任务列表、筛选、详情、状态操作。 | -| 部分完成 | 发布任务 | 入口已识别,进入占位页。 | 迁移任务创建、选择订单、附件上传。 | +| 完成 | 任务管理 | 任务列表、筛选、分页、任务详情、发布任务入口。 | 旧工程里订单/核销订单拼成待办任务的逻辑本轮未迁移,订单仍归订单 Tab 管理。 | +| 完成 | 发布任务 | 发布表单、选择订单、选择云盘文件、本地图片/视频 OSS 上传、提交任务。 | 完整云盘资产管理已由相册云盘模块接管,任务页只保留发布所需选择能力。 | | 部分完成 | 日程管理 | 入口已识别,进入占位页。 | 迁移日程列表、日历和详情。 | | 部分完成 | 打卡点管理 | 入口已识别,景点上下文已具备。 | 迁移打卡点列表、创建/编辑、图片上传。 | | 部分完成 | 项目管理 | `pm` / `pm_manager` / `project_edit` 已识别,进入占位页。 | 迁移项目列表、详情、编辑、新建。 | -| 部分完成 | 相册云盘 | 入口已识别,OSS 上传服务已具备。 | 迁移云盘列表、文件上传、传输管理。 | -| 部分完成 | 相册管理 | 入口已识别,进入占位页。 | 迁移相册文件夹、相册内容、预览上传。 | -| 部分完成 | 素材管理 | 入口已识别,OSS 上传服务已具备。 | 迁移素材列表、上传素材。 | +| 完成 | 相册云盘 | 云盘文件/文件夹浏览、搜索筛选、排序、分页、上传、预览、下载、重命名、移动、删除、传输记录。 | 云盘下载只保存到 App 沙盒 `Documents/CloudDownloads`,不写入系统相册。 | +| 完成 | 相册管理 | 相册列表、搜索筛选、日期筛选、创建相册、相册详情、图片/视频列表、预览、设置封面、删除文件、编辑名称/备注。 | 后续如旧工程补充更多相册运营动作再继续迁移。 | +| 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 | +| 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 样片管理仍按独立模块后续迁移。 | | 部分完成 | 样片管理 | 入口已识别,OSS 上传服务已具备。 | 迁移样片列表、上传样片。 | | 部分完成 | 直播管理 | 入口已识别,进入占位页。 | 迁移直播管理和直播相册。 | | 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 | @@ -111,7 +112,7 @@ | 状态 | 模块 | 说明 | | --- | --- | --- | -| 未开始 | Assets 模块 | 旧工程 `Feature/Assets` 下相册内容、素材内容尚未完整迁移。 | +| 部分完成 | Assets 模块 | 已迁移相册管理、相册预览上传、相册云盘和素材管理;样片管理、上传样片仍未迁移。 | | 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 | | 未开始 | Core/Push | 旧工程推送能力尚未迁移。 | | 未开始 | Core/Queue | 旧工程队列基础能力尚未迁移。 | @@ -123,14 +124,13 @@ 已通过: ```bash -xcodebuild test -project suixinkan.xcodeproj -scheme suixinkan -destination 'platform=iOS Simulator,name=iPhone 17' -xcodebuild build -project suixinkan.xcodeproj -scheme suixinkan -destination 'generic/platform=iOS Simulator' +xcodebuild test -workspace suixinkan.xcworkspace -scheme suixinkan -destination 'platform=iOS Simulator,name=iPhone 17' +xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination 'generic/platform=iOS Simulator' ``` ## 下一批建议迁移顺序 -1. 任务管理/发布任务:已具备 OSS 上传服务,可复用当前上传层。 -2. 相册云盘/素材管理:上传能力已准备好,适合接着迁移文件类模块。 -3. 打卡点管理/位置上报:依赖当前景区和景点上下文,当前上下文已经稳定。 -4. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾。 -5. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试。 +1. 打卡点管理/位置上报:依赖当前景区和景点上下文,当前上下文已经稳定。 +2. 样片管理/上传样片:与素材管理和相册上传相近,可复用素材列表、上传和标签处理经验。 +3. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾。 +4. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试。