Files
suixinkan_ios_new/suixinkan/Features/Assets/Views/AlbumViews.swift
汉秋 703078352c 从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构
将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

705 lines
28 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// AlbumViews.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import AVKit
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
///
struct AlbumListView: View {
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.assetsAPI) private var assetsAPI
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@StateObject 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 {
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.assetsAPI) private var assetsAPI
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@StateObject 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 = StateObject(wrappedValue: 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 {
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.assetsAPI) private var assetsAPI
@Environment(\.ossUploadService) private var uploadService
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss
@Environment(\.globalLoading) private var globalLoading
@StateObject 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
}
}