Add album management and trailer upload to Assets module.
Wire home routing for album_list and album_trailer, add a DEBUG home menu preview in Profile, and expand Assets tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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<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,负责封装相册云盘和素材管理相关接口。
|
||||
@ -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<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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<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
|
||||
}
|
||||
}
|
||||
|
||||
704
suixinkan/Features/Assets/Views/AlbumViews.swift
Normal file
704
suixinkan/Features/Assets/Views/AlbumViews.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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 的映射,并同步补充单元测试。
|
||||
|
||||
@ -36,6 +36,8 @@ enum HomeRoute: Hashable {
|
||||
case cloudStorageTransit
|
||||
case materialLibrary
|
||||
case materialUpload
|
||||
case albumList
|
||||
case albumTrailer
|
||||
case modulePlaceholder(uri: String, title: String)
|
||||
}
|
||||
|
||||
|
||||
@ -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>) -> String {
|
||||
if availableURIs.contains(uri) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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`:用户基础资料。
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
148
suixinkan/Features/Profile/Views/DebugHomeMenuPreviewView.swift
Normal file
148
suixinkan/Features/Profile/Views/DebugHomeMenuPreviewView.swift
Normal 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
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user