对齐旅拍相册详情页 Android UI,并修复网格预览与布局问题。

重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 13:53:59 +08:00
parent c71b45bdfd
commit 5e620623eb
23 changed files with 2000 additions and 288 deletions

View File

@ -177,38 +177,145 @@ struct TravelAlbumCodeSheetState: Identifiable {
var id: Int { albumID }
}
/// ViewModel
/// ViewModel Android TravelAlbumDetailViewModel
@MainActor
final class TravelAlbumDetailViewModel: ObservableObject {
static let tabAll = 0
static let tabPurchased = 1
static let materialStatusUnpurchased = 1
static let sortOptions: [(value: Int, label: String)] = [
(1, "创建时间正序"),
(2, "创建时间倒序"),
(3, "文件名正序"),
(4, "文件名倒序")
]
@Published var album: TravelAlbumItem?
@Published var materials: [TravelAlbumMaterial] = []
@Published var allPhotoCount = 0
@Published var selectedTab = tabAll
@Published var orderBy = 2
@Published var isSelectionMode = false
@Published var selectedMaterialIDs: Set<Int> = []
@Published var isLoading = false
@Published var isDeleting = false
@Published var showDeleteAlbumConfirm = false
@Published var showDeleteMaterialConfirm = false
@Published var errorMessage: String?
///
private var albumID = 0
private var currentPage = 1
private var canLoadMore = false
private var isLoadingMore = false
private static let pageSize = 30
private static let loadMoreThreshold = 6
private static let purchasedFilterYes = 1
///
func refreshAll(api: any TravelAlbumServing, albumID: Int) async {
self.albumID = albumID
async let infoTask: Void = loadAlbumInfo(api: api, albumID: albumID, showPageLoading: album == nil)
async let countTask: Void = loadAllPhotoCount(api: api, albumID: albumID)
async let materialsTask: Void = loadMaterials(api: api, albumID: albumID, reset: true)
_ = await (infoTask, countTask, materialsTask)
}
///
func load(api: any TravelAlbumServing, albumID: Int) async {
isLoading = true
defer { isLoading = false }
do {
async let info = api.albumInfo(id: albumID)
async let list = api.materialList(userEquityTravelId: albumID, page: 1, pageSize: 100)
album = try await info
materials = try await list.list
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
await refreshAll(api: api, albumID: albumID)
}
/// Tab
func selectTab(_ tab: Int, api: any TravelAlbumServing) async {
guard selectedTab != tab else { return }
selectedTab = tab
isSelectionMode = false
selectedMaterialIDs = []
await loadMaterials(api: api, albumID: albumID, reset: true)
}
///
func setOrderBy(_ value: Int, api: any TravelAlbumServing) async {
guard orderBy != value else { return }
orderBy = value
await loadMaterials(api: api, albumID: albumID, reset: true)
}
/// Tab
func toggleSelectionMode() {
guard selectedTab == Self.tabAll else { return }
isSelectionMode.toggle()
if !isSelectionMode {
selectedMaterialIDs = []
}
}
///
func deleteMaterial(api: any TravelAlbumServing, materialID: Int, albumID: Int) async {
do {
try await api.deleteMaterial(TravelAlbumDeleteMaterialRequest(id: materialID))
await load(api: api, albumID: albumID)
} catch {
errorMessage = error.localizedDescription
///
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
guard isSelectionMode else { return }
guard material.status == Self.materialStatusUnpurchased else {
errorMessage = "仅未购买素材可删除"
return
}
if selectedMaterialIDs.contains(material.id) {
selectedMaterialIDs.remove(material.id)
} else {
selectedMaterialIDs.insert(material.id)
}
}
///
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any TravelAlbumServing) async {
guard lastVisibleIndex >= materials.count - Self.loadMoreThreshold else { return }
await loadMaterials(api: api, albumID: albumID, reset: false)
}
///
func requestDeleteSelectedMaterials() {
guard !selectedMaterialIDs.isEmpty else {
errorMessage = "请选择要删除的素材"
return
}
showDeleteMaterialConfirm = true
}
///
@discardableResult
func confirmDeleteSelectedMaterials(api: any TravelAlbumServing) async -> Bool {
let ids = Array(selectedMaterialIDs)
guard !ids.isEmpty else {
showDeleteMaterialConfirm = false
return false
}
showDeleteMaterialConfirm = false
isDeleting = true
defer { isDeleting = false }
for id in ids {
do {
try await api.deleteMaterial(TravelAlbumDeleteMaterialRequest(id: id))
} catch {
errorMessage = error.localizedDescription
return false
}
}
isSelectionMode = false
selectedMaterialIDs = []
await refreshAll(api: api, albumID: albumID)
return true
}
/// 线
func wiredTransferContext() -> WiredTransferContext? {
guard let album else { return nil }
return WiredTransferContext(
albumId: album.id,
albumName: album.name,
phone: album.displayPhone,
orderNumber: album.orderNumber
)
}
///
@ -224,6 +331,80 @@ final class TravelAlbumDetailViewModel: ObservableObject {
return false
}
}
private func loadAlbumInfo(api: any TravelAlbumServing, albumID: Int, showPageLoading: Bool) async {
if showPageLoading {
isLoading = true
}
defer {
if showPageLoading {
isLoading = false
}
}
do {
album = try await api.albumInfo(id: albumID)
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
private func loadAllPhotoCount(api: any TravelAlbumServing, albumID: Int) async {
do {
let payload = try await api.materialList(
userEquityTravelId: albumID,
page: 1,
pageSize: 1,
orderBy: orderBy,
isPurchased: nil
)
allPhotoCount = payload.total
} catch {
errorMessage = error.localizedDescription
}
}
private func loadMaterials(api: any TravelAlbumServing, albumID: Int, reset: Bool) async {
if reset {
currentPage = 1
canLoadMore = false
} else {
guard canLoadMore, !isLoadingMore else { return }
isLoadingMore = true
currentPage += 1
}
let isPurchasedFilter: Int? = selectedTab == Self.tabPurchased ? Self.purchasedFilterYes : nil
do {
let payload = try await api.materialList(
userEquityTravelId: albumID,
page: currentPage,
pageSize: Self.pageSize,
orderBy: orderBy,
isPurchased: isPurchasedFilter
)
if reset {
materials = payload.list
} else {
materials.append(contentsOf: payload.list)
}
canLoadMore = materials.count < payload.total
if selectedTab == Self.tabAll {
allPhotoCount = payload.total
}
errorMessage = nil
} catch {
if !reset, currentPage > 1 {
currentPage -= 1
}
errorMessage = error.localizedDescription
}
if !reset {
isLoadingMore = false
}
}
}
/// 线 ViewModel
@ -237,7 +418,11 @@ final class WiredCameraTransferViewModel: ObservableObject {
static let specifyUploadTodayCaptured = "上传所有今日拍摄的照片"
static let helpDocURL = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink")!
@Published var photos: [WiredTransferPhotoItem] = []
@Published private(set) var photos: [WiredTransferPhotoItem] = []
@Published private(set) var visiblePhotos: [WiredTransferPhotoItem] = []
@Published private(set) var sidebarGroups: [WiredTransferDateGroup] = []
@Published private(set) var photoSections: [WiredTransferPhotoSection] = []
@Published private(set) var tabCounts: [Int] = [0, 0, 0]
@Published var selectedTabIndex = 0
@Published var sidebarExpanded = true
@Published var selectedTimeSlotID: String?
@ -258,6 +443,8 @@ final class WiredCameraTransferViewModel: ObservableObject {
private var photoStore: WiredTransferPhotoStore?
private var userIDProvider: (() -> String)?
private var sessionNewPhotoIDs: Set<String> = []
private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:]
private var lastProgressPersistDates: [String: Date] = [:]
private var wasConnected = false
private var disconnectAlertTask: Task<Void, Never>?
@ -276,7 +463,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
}
pipeline.onTasksUpdated = { [weak self] tasks in
Task { @MainActor in
self?.applyPipelineTasks(tasks)
await self?.applyPipelineTasks(tasks)
}
}
}
@ -325,6 +512,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
func selectTab(_ index: Int) {
selectedTabIndex = index
selectedTimeSlotID = nil
refreshDerivedPhotoState(rebuildGroups: true)
if selectUploadMode {
syncSelectionWithVisiblePhotos()
}
@ -432,16 +620,11 @@ final class WiredCameraTransferViewModel: ObservableObject {
}
}
/// Tab
var visiblePhotos: [WiredTransferPhotoItem] {
photos.filtered(byTabIndex: selectedTabIndex)
}
/// URL
func previewURL(for photoID: String) -> String {
guard let photo = photos.first(where: { $0.id == photoID }) else { return "" }
if !photo.thumbnailURL.isEmpty { return photo.thumbnailURL }
return ""
if !photo.previewURL.isEmpty { return photo.previewURL }
return photo.thumbnailURL
}
///
@ -474,14 +657,48 @@ final class WiredCameraTransferViewModel: ObservableObject {
}
///
func retryPhoto(id: String) async {
await pipeline.uploadAssets(withIDs: [id])
func retryPhoto(
id: String,
api: any TravelAlbumServing,
ossService: any OSSUploadServing,
scenicID: Int
) async {
guard let photo = photos.first(where: { $0.id == id }) else { return }
guard photo.status == .failed || photo.status == .pending else {
errorMessage = "当前状态不可重传"
return
}
ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID)
updatePhotoStatus(id: id, status: .uploading, progress: 0, errorMessage: nil)
if pipeline.task(forAssetID: id) != nil {
await pipeline.uploadAssets(withIDs: [id])
return
}
guard let record = persistedRecord(for: id),
let localURL = CameraDownloadStorage.resolveLocalURL(from: record.localPath),
FileManager.default.fileExists(atPath: localURL.path) else {
updatePhotoStatus(id: id, status: .failed, progress: 0, errorMessage: "本地文件不存在")
errorMessage = "本地文件不存在,无法重传"
persistPhotoStatus(id: id, status: .failed, progress: 0, errorMessage: "本地文件不存在")
return
}
await pipeline.retryUpload(
assetID: id,
filename: record.fileName,
localPath: record.localPath
)
}
///
func deletePhoto(id: String) {
photoStore?.remove(albumID: context.albumId, photoID: id)
photos.removeAll { $0.id == id }
persistedRecordsByID.removeValue(forKey: id)
lastProgressPersistDates.removeValue(forKey: id)
setPhotos(photos.filter { $0.id != id }, rebuildGroups: true)
}
///
@ -515,11 +732,117 @@ final class WiredCameraTransferViewModel: ObservableObject {
pipeline.configure(uploadSink: uploader, uploadEnabled: true)
}
private func persistedRecord(for photoID: String) -> WiredTransferPhotoRecord? {
persistedRecordsByID[photoID]
}
private func updatePhotoStatus(
id: String,
status: WiredTransferUploadStatus,
progress: Int,
errorMessage: String?
) {
guard let index = photos.firstIndex(where: { $0.id == id }) else { return }
let photo = photos[index]
let updated = WiredTransferPhotoItem(
id: photo.id,
sourceId: photo.sourceId,
fileName: photo.fileName,
thumbnailURL: photo.thumbnailURL,
previewURL: photo.previewURL,
capturedAt: photo.capturedAt,
fileSizeText: photo.fileSizeText,
resolutionText: photo.resolutionText,
status: status,
progress: progress,
errorMessage: errorMessage
)
replacePhoto(updated, rebuildGroups: photo.status != status)
}
private func persistPhotoStatus(
id: String,
status: WiredTransferUploadStatus,
progress: Int,
errorMessage: String?
) {
guard context.albumId > 0,
let userID = userIDProvider?(), !userID.isEmpty else { return }
guard let existing = persistedRecordsByID[id] else { return }
let updated = WiredTransferPhotoRecord(
id: existing.id,
sourceId: existing.sourceId,
fileName: existing.fileName,
localPath: existing.localPath,
thumbnailPath: existing.thumbnailPath,
capturedAt: existing.capturedAt,
fileSizeBytes: existing.fileSizeBytes,
status: status.rawValue,
progress: progress,
errorMessage: errorMessage,
albumId: existing.albumId,
userId: existing.userId,
remoteURL: existing.remoteURL,
updatedAt: Date().timeIntervalSince1970
)
persistRecord(updated)
}
private func syncSelectionWithVisiblePhotos() {
let visibleIDs = Set(visiblePhotos.map(\.id))
selectedPhotoIDs = selectedPhotoIDs.intersection(visibleIDs)
}
/// UI
func replacePhotos(_ newPhotos: [WiredTransferPhotoItem]) {
setPhotos(newPhotos.sortedByCaptureTimeDescending(), rebuildGroups: true)
}
private func setPhotos(_ newPhotos: [WiredTransferPhotoItem], rebuildGroups: Bool) {
photos = newPhotos
refreshDerivedPhotoState(rebuildGroups: rebuildGroups)
}
private func replacePhoto(_ photo: WiredTransferPhotoItem, rebuildGroups: Bool) {
var updatedPhotos = photos
if let index = updatedPhotos.firstIndex(where: { $0.id == photo.id }) {
updatedPhotos[index] = photo
} else {
updatedPhotos.insert(photo, at: 0)
}
if rebuildGroups {
updatedPhotos = updatedPhotos.sortedByCaptureTimeDescending()
}
setPhotos(updatedPhotos, rebuildGroups: rebuildGroups)
}
private func refreshDerivedPhotoState(rebuildGroups: Bool) {
tabCounts = photos.transferTabCounts
let filtered = photos.filtered(byTabIndex: selectedTabIndex)
visiblePhotos = filtered
if rebuildGroups {
sidebarGroups = filtered.buildSidebarGroups()
photoSections = filtered.buildPhotoSections()
return
}
let latestByID = Dictionary(uniqueKeysWithValues: filtered.map { ($0.id, $0) })
photoSections = photoSections.map { section in
WiredTransferPhotoSection(
slotId: section.slotId,
headerTitle: section.headerTitle,
photos: section.photos.compactMap { latestByID[$0.id] }
)
}.filter { !$0.photos.isEmpty }
}
private func persistRecord(_ record: WiredTransferPhotoRecord) {
guard let photoStore, context.albumId > 0 else { return }
persistedRecordsByID[record.id] = record
photoStore.save(albumID: context.albumId, records: Array(persistedRecordsByID.values))
}
private func handleConnectionStateChange(_ state: CameraConnectionState) {
if wasConnected, !state.isConnected {
triggerCameraDisconnectedAlert()
@ -553,73 +876,182 @@ final class WiredCameraTransferViewModel: ObservableObject {
private func loadPersistedPhotos() {
guard context.albumId > 0, let photoStore else { return }
let records = photoStore.load(albumID: context.albumId)
photos = records.map { $0.toPhotoItem() }
persistedRecordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
setPhotos(records.map { $0.toPhotoItem() }.sortedByCaptureTimeDescending(), rebuildGroups: true)
}
private func applyPipelineTasks(_ tasks: [CameraTransferTask]) {
guard let photoStore, let userIDProvider else { return }
private func applyPipelineTasks(_ tasks: [CameraTransferTask]) async {
guard let userIDProvider else { return }
let userID = userIDProvider()
var merged = photos.filter { photo in
!tasks.contains { $0.assetID == photo.id || $0.filename == photo.fileName }
}
// capturedAt
let existingByID = Dictionary(uniqueKeysWithValues: photos.map { ($0.id, $0) })
let existingByFileName = Dictionary(
photos.map { ($0.fileName, $0) },
uniquingKeysWith: { first, _ in first }
)
for task in tasks {
guard belongsToCurrentAlbum(task.assetID) else { continue }
let status: WiredTransferUploadStatus
switch task.status {
case .pending, .downloading:
status = .transferring
case .downloaded:
status = .pending
case .uploading:
status = .uploading
case .uploaded:
status = .uploaded
case .failed:
status = .failed
}
let capturedAt = Self.captureTimestampFormatter.string(from: Date())
let status = mapPipelineStatus(task.status)
let storedLocalPath = task.localPath ?? ""
let localURL = task.localURL
let fileSizeBytes: Int64
if let localURL {
fileSizeBytes = (try? FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int64) ?? 0
} else {
fileSizeBytes = 0
let fileSizeBytes = await CameraDownloadStorage.fileSizeBytes(for: localURL)
let existing = existingByID[task.assetID] ?? existingByFileName[task.filename]
let existingRecord = persistedRecordsByID[task.assetID]
let capturedAt = await resolveCapturedAt(
existing: existing,
taskCapturedAt: task.capturedAt,
localURL: localURL
)
var thumbnailPath = existingRecord?.thumbnailPath ?? ""
if thumbnailPath.isEmpty,
let localURL,
status != .transferring,
let generatedPath = await CameraThumbnailGenerator.generateThumbnail(for: localURL, assetID: task.assetID) {
thumbnailPath = generatedPath
}
let thumbnailURL = localURL.map(\.absoluteString) ?? ""
let thumbnailURL = thumbnailURLString(thumbnailPath: thumbnailPath, remoteURL: task.remoteURL ?? existingRecord?.remoteURL ?? "")
let previewURL = previewURLString(localPath: storedLocalPath, remoteURL: task.remoteURL ?? existingRecord?.remoteURL ?? "", thumbnailPath: thumbnailPath)
let fileSizeText = fileSizeBytes > 0
? WiredTransferPhotoRecord.formatFileSize(fileSizeBytes)
: (existing?.fileSizeText ?? "--")
let item = WiredTransferPhotoItem(
id: task.assetID,
sourceId: existing?.sourceId ?? task.assetID,
fileName: task.filename,
thumbnailURL: thumbnailURL,
previewURL: previewURL,
capturedAt: capturedAt,
fileSizeText: WiredTransferPhotoRecord.formatFileSize(fileSizeBytes),
fileSizeText: fileSizeText,
resolutionText: existing?.resolutionText ?? "",
status: status,
progress: task.progress,
errorMessage: task.errorMessage
)
merged.insert(item, at: 0)
let rebuildGroups = existing == nil
|| existing?.capturedAt != item.capturedAt
|| (selectedTabIndex != 0 && existing?.status != item.status)
replacePhoto(item, rebuildGroups: rebuildGroups)
sessionNewPhotoIDs.insert(task.assetID)
guard !userID.isEmpty else { continue }
let record = item.toRecord(
albumId: context.albumId,
userId: userID,
let remoteURL = task.remoteURL ?? ""
if shouldPersistPipelineUpdate(
photoID: task.assetID,
status: status,
localPath: storedLocalPath,
fileSizeBytes: fileSizeBytes,
remoteURL: task.remoteURL ?? ""
)
var records = photoStore.load(albumID: context.albumId)
records.removeAll { $0.id == record.id }
records.insert(record, at: 0)
photoStore.save(albumID: context.albumId, records: records)
thumbnailPath: thumbnailPath,
remoteURL: remoteURL,
progress: task.progress,
now: Date()
) {
let record = item.toRecord(
albumId: context.albumId,
userId: userID,
localPath: storedLocalPath,
thumbnailPath: thumbnailPath,
fileSizeBytes: fileSizeBytes,
remoteURL: remoteURL
)
persistRecord(record)
}
}
}
photos = merged.sorted { $0.capturedAt > $1.capturedAt }
/// 使退
private func thumbnailURLString(thumbnailPath: String, remoteURL: String) -> String {
if !thumbnailPath.isEmpty {
let localThumbnailURL = CameraDownloadStorage.previewURLString(from: thumbnailPath)
if !localThumbnailURL.isEmpty { return localThumbnailURL }
}
return remoteURL
}
private func previewURLString(localPath: String, remoteURL: String, thumbnailPath: String) -> String {
if !localPath.isEmpty {
let localPreviewURL = CameraDownloadStorage.previewURLString(from: localPath)
if !localPreviewURL.isEmpty { return localPreviewURL }
}
if !remoteURL.isEmpty { return remoteURL }
if !thumbnailPath.isEmpty {
return CameraDownloadStorage.previewURLString(from: thumbnailPath)
}
return ""
}
private func resolveCapturedAt(
existing: WiredTransferPhotoItem?,
taskCapturedAt: String?,
localURL: URL?
) async -> String {
if let existing, !existing.capturedAt.isEmpty {
return existing.capturedAt
}
if let taskCapturedAt, !taskCapturedAt.isEmpty {
return taskCapturedAt
}
if let localURL {
let date = await Task.detached(priority: .utility) {
let attributes = try? FileManager.default.attributesOfItem(atPath: localURL.path)
return (attributes?[.creationDate] as? Date)
?? (attributes?[.modificationDate] as? Date)
}.value
if let date {
return CameraTransferTask.capturedAtString(from: date)
}
}
return CameraTransferTask.capturedAtString(from: Date())
}
///
private func mapPipelineStatus(_ status: CameraTransferStatus) -> WiredTransferUploadStatus {
switch status {
case .pending, .downloading:
return .transferring
case .downloaded:
return .pending
case .uploading:
return .uploading
case .uploaded:
return .uploaded
case .failed:
return .failed
}
}
/// URL IO
private func shouldPersistPipelineUpdate(
photoID: String,
status: WiredTransferUploadStatus,
localPath: String,
thumbnailPath: String,
remoteURL: String,
progress: Int,
now: Date
) -> Bool {
guard let existing = persistedRecordsByID[photoID] else {
lastProgressPersistDates[photoID] = now
return true
}
if existing.status != status.rawValue { return true }
if !localPath.isEmpty, existing.localPath != localPath { return true }
if !thumbnailPath.isEmpty, existing.thumbnailPath != thumbnailPath { return true }
if !remoteURL.isEmpty, existing.remoteURL != remoteURL { return true }
if status == .uploaded || status == .failed { return true }
if abs(progress - existing.progress) >= 10 {
lastProgressPersistDates[photoID] = now
return true
}
let lastPersistDate = lastProgressPersistDates[photoID] ?? .distantPast
if now.timeIntervalSince(lastPersistDate) >= 1 {
lastProgressPersistDates[photoID] = now
return true
}
return false
}
///
@ -629,10 +1061,4 @@ final class WiredCameraTransferViewModel: ObservableObject {
}
return bound == context.albumId
}
private static let captureTimestampFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
}