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:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user