为资产模块新增相册管理与片花上传

为 album_list 与 album_trailer 接入首页路由,在 Profile 中新增 DEBUG 首页菜单预览,并扩展 Assets 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 09:43:46 +08:00
parent cac22fcc56
commit ae0bc116d8
17 changed files with 2018 additions and 26 deletions

View File

@ -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记录请求并返回固定响应。

View File

@ -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<CloudDriveFile>] = [ListPayload(total: 0, list: [])]
var mediaListResponses: [MediaLibraryListResponse] = [MediaLibraryListResponse.fixture(total: 0, ids: [])]
var albumFolderPages: [ListPayload<AlbumFolderItem>] = [ListPayload(total: 0, list: [])]
var albumFilePages: [ListPayload<AlbumFileItem>] = [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<CloudDriveFile> {
@ -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<AlbumFolderItem> {
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<AlbumFileItem> {
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)
}
}