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

为 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

@ -55,6 +55,27 @@ protocol AssetsServing {
/// ///
func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws
///
func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload<AlbumFolderItem>
///
func albumFolderInfo(id: Int) async throws -> AlbumFolderItem
///
func albumFileList(scenicId: Int, folderId: Int, fileType: Int, page: Int, pageSize: Int) async throws -> ListPayload<AlbumFileItem>
///
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 /// API
@ -209,4 +230,120 @@ final class AssetsAPI: AssetsServing {
APIRequest(method: .post, path: "/api/app/media-album/edit", body: request) APIRequest(method: .post, path: "/api/app/media-album/edit", body: request)
) as EmptyPayload ) as EmptyPayload
} }
///
func albumFolderList(
scenicId: Int,
page: Int = 1,
pageSize: Int = 10,
name: String = "",
startTime: String? = nil,
endTime: String? = nil
) async throws -> ListPayload<AlbumFolderItem> {
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<AlbumFileItem> {
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
}
} }

View File

@ -2,14 +2,45 @@
## 模块职责 ## 模块职责
Assets 模块负责首页中的相册云盘和素材管理入口。 Assets 模块负责首页中的相册、相册云盘和素材管理入口。
- `album_list` 进入 `AlbumListView`
- `album_trailer` 进入 `AlbumTrailerEntryView`
- `cloud_management` 进入 `CloudStorageView` - `cloud_management` 进入 `CloudStorageView`
- `cloud_storage_transit` 进入 `CloudStorageTransitView` - `cloud_storage_transit` 进入 `CloudStorageTransitView`
- `asset_management` 进入 `MediaLibraryView(kind: .material)` - `asset_management` 进入 `MediaLibraryView(kind: .material)`
- `material_upload` 进入 `MediaLibraryUploadView` - `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` 图片缓存继续交给 Kingfisher 的 `RemoteImage`。业务模块不自行缓存远程图片文件或 `Data`
## 测试要求 ## 测试要求
新增云盘或素材逻辑时,需要同步补充 API、ViewModel 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。 新增相册、云盘或素材逻辑时,需要同步补充 API、ViewModel 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。

View File

@ -675,6 +675,215 @@ struct MediaLocalUploadFile: Identifiable, Equatable {
let height: Int 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 { private extension KeyedDecodingContainer {
/// String Bool /// String Bool
func decodeLossyString(forKey key: Key) throws -> String { func decodeLossyString(forKey key: Key) throws -> String {

View File

@ -578,3 +578,367 @@ final class MediaLibraryEditorViewModel {
.joined(separator: ",") .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<AlbumFolderItem> {
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
}
}

View File

@ -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<Bool> {
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
}
}

View File

@ -33,6 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
## 后续迁移 ## 后续迁移
目前 `payment_collection``payment_qr``payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection``permission_apply``permission_apply_status``scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management``task_management_editor``task_create` 已由 `Features/Tasks` 接管,`cloud_management``cloud_storage_transit``asset_management``material_upload` 已由 `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 的映射,并同步补充单元测试。 后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。

View File

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

View File

@ -103,6 +103,10 @@ enum HomeMenuRouter {
return .destination(.materialLibrary) return .destination(.materialLibrary)
case "material_upload": case "material_upload":
return .destination(.materialUpload) return .destination(.materialUpload)
case "album_list":
return .destination(.albumList)
case "album_trailer":
return .destination(.albumTrailer)
case "store", "more_functions": case "store", "more_functions":
return .destination(.moreFunctions) return .destination(.moreFunctions)
case "fly", "pilot_controller": case "fly", "pilot_controller":
@ -123,8 +127,6 @@ enum HomeMenuRouter {
"pm", "pm",
"pm_manager", "pm_manager",
"project_edit", "project_edit",
"album_list",
"album_trailer",
"sample_management", "sample_management",
"sample_upload", "sample_upload",
"live_stream_management", "live_stream_management",
@ -150,6 +152,71 @@ enum HomeMenuRouter {
titleMap[uri] ?? uri 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 /// URI
static func canonicalURI(for uri: String, availableURIs: Set<String>) -> String { static func canonicalURI(for uri: String, availableURIs: Set<String>) -> String {
if availableURIs.contains(uri) { if availableURIs.contains(uri) {

View File

@ -44,6 +44,10 @@ extension HomeRoute {
MediaLibraryView() MediaLibraryView()
case .materialUpload: case .materialUpload:
MediaLibraryUploadView() MediaLibraryUploadView()
case .albumList:
AlbumListView()
case .albumTrailer:
AlbumTrailerEntryView()
case let .modulePlaceholder(uri, title): case let .modulePlaceholder(uri, title):
HomeMigrationModuleView(title: title, uri: uri) HomeMigrationModuleView(title: title, uri: uri)
} }

View File

@ -17,6 +17,8 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括
登录态清理和缓存清理由 App 模块的 `AuthSessionCoordinator` 统一处理。 登录态清理和缓存清理由 App 模块的 `AuthSessionCoordinator` 统一处理。
DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,进入 `DebugHomeMenuPreviewView` 后可查看并跳转所有已知首页菜单,不读取角色权限。该入口只用于迁移预览和调试,不进入 Release 包。
## 核心对象 ## 核心对象
- `ProfileView`:个人信息页 UI负责展示资料、编辑入口、密码弹窗和退出确认。 - `ProfileView`:个人信息页 UI负责展示资料、编辑入口、密码弹窗和退出确认。
@ -25,6 +27,7 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括
- `AccountSwitchView` / `AccountSwitchViewModel`:账号切换页面和状态逻辑。 - `AccountSwitchView` / `AccountSwitchViewModel`:账号切换页面和状态逻辑。
- `RealNameAuthView` / `RealNameAuthViewModel`:实名认证页面和表单逻辑。 - `RealNameAuthView` / `RealNameAuthViewModel`:实名认证页面和表单逻辑。
- `SettingsCenterView` / `AgreementView`:设置中心和协议 H5 页面。 - `SettingsCenterView` / `AgreementView`:设置中心和协议 H5 页面。
- `DebugHomeMenuPreviewView`DEBUG 专用首页菜单预览页,不参与正式业务流程。
- `ProfileAPI`:封装用户资料、实名认证和资料更新接口。 - `ProfileAPI`:封装用户资料、实名认证和资料更新接口。
- `OSSUploadService`:上传头像和实名认证证件图片,返回可提交给业务接口的 OSS URL。 - `OSSUploadService`:上传头像和实名认证证件图片,返回可提交给业务接口的 OSS URL。
- `UserInfoResponse`:用户基础资料。 - `UserInfoResponse`:用户基础资料。

View File

@ -13,6 +13,9 @@ enum ProfileRoute: Hashable {
case realNameAuth case realNameAuth
case settings case settings
case agreement(AgreementPage) case agreement(AgreementPage)
#if DEBUG
case debugHomeMenus
#endif
/// SwiftUI /// SwiftUI
@ViewBuilder @ViewBuilder
@ -26,6 +29,10 @@ enum ProfileRoute: Hashable {
SettingsCenterView() SettingsCenterView()
case .agreement(let page): case .agreement(let page):
AgreementView(page: page) AgreementView(page: page)
#if DEBUG
case .debugHomeMenus:
DebugHomeMenuPreviewView()
#endif
} }
} }
} }

View File

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

View File

@ -322,6 +322,20 @@ struct ProfileView: View {
} }
} }
.buttonStyle(.plain) .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(.horizontal, 24)
.padding(.vertical, 4) .padding(.vertical, 4)

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") 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 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 permissionResponse = #"{"code":100000,"msg":"success","data":{"can_upload":"0","reason":""}}"#.data(using: .utf8)!
fileprivate static let cloudListResponse = """ 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"} {"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)! """.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记录请求并返回固定响应。 /// 资产 API 测试用 URLSession记录请求并返回固定响应。

View File

@ -144,6 +144,121 @@ final class AssetsViewModelTests: XCTestCase {
XCTAssertFalse(success) XCTAssertFalse(success)
XCTAssertEqual(api.mediaUploadRequests.count, 0) 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 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 cloudPages: [ListPayload<CloudDriveFile>] = [ListPayload(total: 0, list: [])]
var mediaListResponses: [MediaLibraryListResponse] = [MediaLibraryListResponse.fixture(total: 0, ids: [])] 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 uploadPermission = true
var uploadReason = "" var uploadReason = ""
var cloudListRequests: [CloudListRequest] = [] var cloudListRequests: [CloudListRequest] = []
@ -174,6 +306,13 @@ private final class MockAssetsService: AssetsServing {
var mediaOperationRequests: [MediaAlbumOperationRequest] = [] var mediaOperationRequests: [MediaAlbumOperationRequest] = []
var mediaUploadRequests: [MediaAlbumUploadRequest] = [] var mediaUploadRequests: [MediaAlbumUploadRequest] = []
var mediaEditRequests: [MediaAlbumEditRequest] = [] 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> { 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 { func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws {
mediaEditRequests.append(request) 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 /// OSS CDN URL
@ -362,3 +539,39 @@ private extension MediaLibraryDetail {
return try! JSONDecoder().decode(MediaLibraryDetail.self, from: data) 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)
}
}

View File

@ -39,6 +39,8 @@ final class HomeMenuRouterTests: XCTestCase {
XCTAssertEqual(HomeMenuRouter.resolve(uri: "cloud_storage_transit", title: ""), .destination(.cloudStorageTransit)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "cloud_storage_transit", title: ""), .destination(.cloudStorageTransit))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "asset_management", title: ""), .destination(.materialLibrary)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "asset_management", title: ""), .destination(.materialLibrary))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "material_upload", title: ""), .destination(.materialUpload)) 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: ""), HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""),
.destination(.modulePlaceholder(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( XCTAssertEqual(
HomeMenuRouter.resolve(uri: "sample_upload", title: ""), HomeMenuRouter.resolve(uri: "sample_upload", title: ""),
.destination(.modulePlaceholder(uri: "sample_upload", title: "上传样片")) .destination(.modulePlaceholder(uri: "sample_upload", title: "上传样片"))
@ -159,6 +157,23 @@ final class HomeMenuRouterTests: XCTestCase {
XCTAssertTrue(audit.hasUnknownRoutes) 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 { private func rolePermission(roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse {
RolePermissionResponse( RolePermissionResponse(

View File

@ -1,6 +1,6 @@
# 功能同步 Checklist # 功能同步 Checklist
更新时间2026-06-23 更新时间2026-06-24
## 状态说明 ## 状态说明
@ -18,7 +18,7 @@
| 完成 | 全局状态拆分 | `AppSession``AccountContext``PermissionContext``ScenicSpotContext``AppRouter``ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 | | 完成 | 全局状态拆分 | `AppSession``AccountContext``PermissionContext``ScenicSpotContext``AppRouter``ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 |
| 完成 | 登录态缓存 | token 使用 Keychain账号快照、上次手机号、偏好使用 UserDefaults冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 | | 完成 | 登录态缓存 | token 使用 Keychain账号快照、上次手机号、偏好使用 UserDefaults冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 |
| 完成 | 网络框架 | `APIClient``APIRequest``APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 | | 完成 | 网络框架 | `APIClient``APIRequest``APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 |
| 完成 | OSS 上传 | `UploadAPI``OSSUploadService`、上传策略、头像实名图片压缩处理。 | 后续云盘、相册、任务等模块迁移时复用。 | | 完成 | OSS 上传 | `UploadAPI``OSSUploadService`、上传策略、头像实名图片、任务附件、云盘文件、相册文件、素材文件上传处理。 | 后续样片等模块迁移时继续复用。 |
| 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 | | 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 |
| 完成 | 设计常量 | `AppDesign``AppMetrics` 统一颜色、字号、间距等通用尺寸。 | 继续替换新页面中的零散常量。 | | 完成 | 设计常量 | `AppDesign``AppMetrics` 统一颜色、字号、间距等通用尺寸。 | 继续替换新页面中的零散常量。 |
| 完成 | 文档规范 | 已有 `AGENTS.md` 约束;现有模块均有模块说明文档。 | 后续每个新功能模块同步新增模块 md。 | | 完成 | 文档规范 | 已有 `AGENTS.md` 约束;现有模块均有模块说明文档。 | 后续每个新功能模块同步新增模块 md。 |
@ -88,14 +88,15 @@
| 部分完成 | 排队管理 | `/scenic-queue` / `queue_management` 已识别,进入占位页。 | 迁移队列列表、叫号、设置等流程。 | | 部分完成 | 排队管理 | `/scenic-queue` / `queue_management` 已识别,进入占位页。 | 迁移队列列表、叫号、设置等流程。 |
| 完成 | 立即收款/收款码 | 收款码、设置金额、动态二维码、保存二维码、收款记录、到账轮询、语音提醒。 | 后续可按业务继续优化收款反馈细节。 | | 完成 | 立即收款/收款码 | 收款码、设置金额、动态二维码、保存二维码、收款记录、到账轮询、语音提醒。 | 后续可按业务继续优化收款反馈细节。 |
| 部分完成 | 押金订单 | 入口已识别,进入占位页。 | 迁移押金订单详情和拍摄信息。 | | 部分完成 | 押金订单 | 入口已识别,进入占位页。 | 迁移押金订单详情和拍摄信息。 |
| 部分完成 | 任务管理 | 入口已识别,进入占位页。 | 迁移任务列表、筛选、详情、状态操作。 | | 完成 | 任务管理 | 任务列表、筛选、分页、任务详情、发布任务入口。 | 旧工程里订单/核销订单拼成待办任务的逻辑本轮未迁移,订单仍归订单 Tab 管理。 |
| 部分完成 | 发布任务 | 入口已识别,进入占位页。 | 迁移任务创建、选择订单、附件上传。 | | 完成 | 发布任务 | 发布表单、选择订单、选择云盘文件、本地图片/视频 OSS 上传、提交任务。 | 完整云盘资产管理已由相册云盘模块接管,任务页只保留发布所需选择能力。 |
| 部分完成 | 日程管理 | 入口已识别,进入占位页。 | 迁移日程列表、日历和详情。 | | 部分完成 | 日程管理 | 入口已识别,进入占位页。 | 迁移日程列表、日历和详情。 |
| 部分完成 | 打卡点管理 | 入口已识别,景点上下文已具备。 | 迁移打卡点列表、创建/编辑、图片上传。 | | 部分完成 | 打卡点管理 | 入口已识别,景点上下文已具备。 | 迁移打卡点列表、创建/编辑、图片上传。 |
| 部分完成 | 项目管理 | `pm` / `pm_manager` / `project_edit` 已识别,进入占位页。 | 迁移项目列表、详情、编辑、新建。 | | 部分完成 | 项目管理 | `pm` / `pm_manager` / `project_edit` 已识别,进入占位页。 | 迁移项目列表、详情、编辑、新建。 |
| 部分完成 | 相册云盘 | 入口已识别OSS 上传服务已具备。 | 迁移云盘列表、文件上传、传输管理。 | | 完成 | 相册云盘 | 云盘文件/文件夹浏览、搜索筛选、排序、分页、上传、预览、下载、重命名、移动、删除、传输记录。 | 云盘下载只保存到 App 沙盒 `Documents/CloudDownloads`,不写入系统相册。 |
| 部分完成 | 相册管理 | 入口已识别,进入占位页。 | 迁移相册文件夹、相册内容、预览上传。 | | 完成 | 相册管理 | 相册列表、搜索筛选、日期筛选、创建相册、相册详情、图片/视频列表、预览、设置封面、删除文件、编辑名称/备注。 | 后续如旧工程补充更多相册运营动作再继续迁移。 |
| 部分完成 | 素材管理 | 入口已识别OSS 上传服务已具备。 | 迁移素材列表、上传素材。 | | 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 |
| 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 样片管理仍按独立模块后续迁移。 |
| 部分完成 | 样片管理 | 入口已识别OSS 上传服务已具备。 | 迁移样片列表、上传样片。 | | 部分完成 | 样片管理 | 入口已识别OSS 上传服务已具备。 | 迁移样片列表、上传样片。 |
| 部分完成 | 直播管理 | 入口已识别,进入占位页。 | 迁移直播管理和直播相册。 | | 部分完成 | 直播管理 | 入口已识别,进入占位页。 | 迁移直播管理和直播相册。 |
| 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 | | 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 |
@ -111,7 +112,7 @@
| 状态 | 模块 | 说明 | | 状态 | 模块 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| 未开始 | Assets 模块 | 旧工程 `Feature/Assets` 下相册内容、素材内容尚未完整迁移。 | | 部分完成 | Assets 模块 | 已迁移相册管理、相册预览上传、相册云盘和素材管理;样片管理、上传样片仍未迁移。 |
| 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 | | 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 |
| 未开始 | Core/Push | 旧工程推送能力尚未迁移。 | | 未开始 | Core/Push | 旧工程推送能力尚未迁移。 |
| 未开始 | Core/Queue | 旧工程队列基础能力尚未迁移。 | | 未开始 | Core/Queue | 旧工程队列基础能力尚未迁移。 |
@ -123,14 +124,13 @@
已通过: 已通过:
```bash ```bash
xcodebuild test -project suixinkan.xcodeproj -scheme suixinkan -destination 'platform=iOS Simulator,name=iPhone 17' xcodebuild test -workspace suixinkan.xcworkspace -scheme suixinkan -destination 'platform=iOS Simulator,name=iPhone 17'
xcodebuild build -project suixinkan.xcodeproj -scheme suixinkan -destination 'generic/platform=iOS Simulator' xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination 'generic/platform=iOS Simulator'
``` ```
## 下一批建议迁移顺序 ## 下一批建议迁移顺序
1. 任务管理/发布任务:已具备 OSS 上传服务,可复用当前上传层 1. 打卡点管理/位置上报:依赖当前景区和景点上下文,当前上下文已经稳定
2. 相册云盘/素材管理:上传能力已准备好,适合接着迁移文件类模块 2. 样片管理/上传样片:与素材管理和相册上传相近,可复用素材列表、上传和标签处理经验
3. 打卡点管理/位置上报:依赖当前景区和景点上下文,当前上下文已经稳定 3. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾
4. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾 4. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试
5. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试。