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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 13:53:59 +08:00

636 lines
20 KiB
Swift
Raw Permalink 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.

//
// TravelAlbumModels.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
///
struct TravelAlbumUser: Decodable, Equatable, Hashable {
let id: Int
let phone: String
enum CodingKeys: String, CodingKey {
case id, phone
}
init(id: Int = 0, phone: String = "") {
self.id = id
self.phone = phone
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = container.lossyInt(forKey: .id)
phone = container.lossyString(forKey: .phone)
}
}
///
struct TravelAlbumItem: Decodable, Identifiable, Equatable, Hashable {
let id: Int
let storeUserId: Int
let name: String
/// 1 2
let type: Int
let orderNumber: String
let materialNum: Int
let materialPrice: Int
let materialPackagePrice: Int
let photoPrice: Int
let coverURL: String
let userId: Int
let status: Int
let createdAt: String
let updatedAt: String
let user: TravelAlbumUser?
enum CodingKeys: String, CodingKey {
case id
case storeUserId = "store_user_id"
case name, type
case orderNumber = "order_number"
case materialNum = "material_num"
case materialPrice = "material_price"
case materialPackagePrice = "material_package_price"
case photoPrice = "photo_price"
case coverURL = "cover_url"
case userId = "user_id"
case status
case createdAt = "created_at"
case updatedAt = "updated_at"
case user
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = container.lossyInt(forKey: .id)
storeUserId = container.lossyInt(forKey: .storeUserId)
name = container.lossyString(forKey: .name)
type = container.lossyInt(forKey: .type)
orderNumber = container.lossyString(forKey: .orderNumber)
materialNum = container.lossyInt(forKey: .materialNum)
materialPrice = container.lossyInt(forKey: .materialPrice)
materialPackagePrice = container.lossyInt(forKey: .materialPackagePrice)
photoPrice = container.lossyInt(forKey: .photoPrice)
coverURL = container.lossyString(forKey: .coverURL)
userId = container.lossyInt(forKey: .userId)
status = container.lossyInt(forKey: .status)
createdAt = container.lossyString(forKey: .createdAt)
updatedAt = container.lossyString(forKey: .updatedAt)
user = try? container.decodeIfPresent(TravelAlbumUser.self, forKey: .user)
}
var displayPhone: String {
user?.phone ?? ""
}
}
///
struct TravelAlbumAvailableOrder: Decodable, Identifiable, Equatable, Hashable {
let projectName: String
let orderNumber: String
let userPhone: String
let userId: Int
var id: String { orderNumber }
enum CodingKeys: String, CodingKey {
case projectName = "project_name"
case orderNumber = "order_number"
case userPhone = "user_phone"
case userId = "user_id"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
projectName = container.lossyString(forKey: .projectName)
orderNumber = container.lossyString(forKey: .orderNumber)
userPhone = container.lossyString(forKey: .userPhone)
userId = container.lossyInt(forKey: .userId)
}
}
///
struct TravelAlbumMaterial: Decodable, Identifiable, Equatable {
let id: Int
let userEquityTravelId: Int
let status: Int
let orderNumber: String
let userId: Int
let fileName: String
let fileType: Int
let fileURL: String
let fileSize: Int
let coverURL: String
let isPurchased: Bool
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id
case userEquityTravelId = "user_equity_travel_id"
case status
case orderNumber = "order_number"
case userId = "user_id"
case fileName = "file_name"
case fileType = "file_type"
case fileURL = "file_url"
case fileSize = "file_size"
case coverURL = "cover_url"
case isPurchased = "is_purchased"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = container.lossyInt(forKey: .id)
userEquityTravelId = container.lossyInt(forKey: .userEquityTravelId)
status = container.lossyInt(forKey: .status)
orderNumber = container.lossyString(forKey: .orderNumber)
userId = container.lossyInt(forKey: .userId)
fileName = container.lossyString(forKey: .fileName)
fileType = container.lossyInt(forKey: .fileType)
fileURL = container.lossyString(forKey: .fileURL)
fileSize = container.lossyInt(forKey: .fileSize)
coverURL = container.lossyString(forKey: .coverURL)
isPurchased = container.lossyBool(forKey: .isPurchased)
createdAt = container.lossyString(forKey: .createdAt)
updatedAt = container.lossyString(forKey: .updatedAt)
}
/// URL
var previewURLString: String {
if !fileURL.isEmpty { return fileURL }
return coverURL
}
/// URL
var thumbnailURLString: String {
if !coverURL.isEmpty { return coverURL }
return fileURL
}
}
///
struct TravelAlbumCreateRequest: Encodable {
let name: String
let type: Int
let orderNumber: String?
let materialNum: Int?
let materialPrice: Double?
let materialPackagePrice: Double?
let photoPrice: Double?
enum CodingKeys: String, CodingKey {
case name, type
case orderNumber = "order_number"
case materialNum = "material_num"
case materialPrice = "material_price"
case materialPackagePrice = "material_package_price"
case photoPrice = "photo_price"
}
}
///
struct TravelAlbumCreateResponse: Decodable {
let id: Int
init(id: Int) {
self.id = id
}
enum CodingKeys: String, CodingKey {
case id
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = container.lossyInt(forKey: .id)
}
}
///
struct TravelAlbumUploadMaterialRequest: Encodable {
let userEquityTravelId: Int
let fileName: String
let fileURL: String
enum CodingKeys: String, CodingKey {
case userEquityTravelId = "user_equity_travel_id"
case fileName = "file_name"
case fileURL = "file_url"
}
}
///
struct TravelAlbumDeleteRequest: Encodable {
let id: Int
}
///
struct TravelAlbumEditRequest: Encodable {
let id: Int
let name: String?
let materialPackagePrice: Double?
enum CodingKeys: String, CodingKey {
case id, name
case materialPackagePrice = "material_package_price"
}
}
///
struct TravelAlbumDeleteMaterialRequest: Encodable {
let id: Int
}
///
struct TravelAlbumMpCodeResponse: Decodable {
let mpCodeOssURL: String
init(mpCodeOssURL: String) {
self.mpCodeOssURL = mpCodeOssURL
}
enum CodingKeys: String, CodingKey {
case mpCodeOssURL = "mp_code_oss_url"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
mpCodeOssURL = container.lossyString(forKey: .mpCodeOssURL)
}
}
/// 线
struct WiredTransferContext: Hashable {
let albumId: Int
let albumName: String
let phone: String
let orderNumber: String
}
/// 线
enum WiredTransferUploadStatus: String, Codable, Equatable {
case uploaded = "UPLOADED"
case pending = "PENDING"
case transferring = "TRANSFERRING"
case uploading = "UPLOADING"
case failed = "FAILED"
var displayLabel: String {
switch self {
case .uploaded: "已上传"
case .pending: "待上传"
case .transferring: "传输中"
case .uploading: "上传中"
case .failed: "上传失败"
}
}
}
/// 线
struct WiredTransferTimeSlot: Identifiable, Equatable {
let id: String
let dateLabel: String
let slotStartLabel: String
let photoCount: Int
}
/// 线
struct WiredTransferDateGroup: Equatable {
let dateLabel: String
let slots: [WiredTransferTimeSlot]
}
/// 线30
struct WiredTransferPhotoSection: Identifiable, Equatable {
var id: String { slotId }
let slotId: String
let headerTitle: String
let photos: [WiredTransferPhotoItem]
}
/// 线
struct WiredTransferPhotoItem: Identifiable, Equatable {
let id: String
let sourceId: String
let fileName: String
/// 使 URL
let thumbnailURL: String
/// 使 URL
let previewURL: String
let capturedAt: String
let fileSizeText: String
let resolutionText: String
let status: WiredTransferUploadStatus
let progress: Int
let errorMessage: String?
init(
id: String,
sourceId: String? = nil,
fileName: String,
thumbnailURL: String,
previewURL: String = "",
capturedAt: String,
fileSizeText: String,
resolutionText: String = "",
status: WiredTransferUploadStatus,
progress: Int = 0,
errorMessage: String? = nil
) {
self.id = id
self.sourceId = sourceId ?? id
self.fileName = fileName
self.thumbnailURL = thumbnailURL
self.previewURL = previewURL
self.capturedAt = capturedAt
self.fileSizeText = fileSizeText
self.resolutionText = resolutionText
self.status = status
self.progress = progress
self.errorMessage = errorMessage
}
var canSelectForSpecifyUpload: Bool {
status == .pending
}
var isNotUploaded: Bool {
status == .pending || status == .failed
}
///
func capturedDate() -> Date? {
Self.capturedAtFormatter.date(from: capturedAt)
}
///
func isCapturedToday(calendar: Calendar = .current) -> Bool {
guard let date = capturedDate() else { return false }
return calendar.isDateInToday(date)
}
private static let capturedAtFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
}
/// 线
struct WiredTransferPhotoRecord: Codable, Equatable {
let id: String
let sourceId: String
let fileName: String
let localPath: String
let thumbnailPath: String
let capturedAt: String
let fileSizeBytes: Int64
let status: String
let progress: Int
let errorMessage: String?
let albumId: Int
let userId: String
let remoteURL: String
let updatedAt: TimeInterval
func toPhotoItem() -> WiredTransferPhotoItem {
let thumbnailCandidates = [thumbnailPath, remoteURL]
let thumbnailURL = thumbnailCandidates.compactMap { path -> String? in
guard !path.isEmpty else { return nil }
if path.hasPrefix("http") { return path }
let preview = CameraDownloadStorage.previewURLString(from: path)
return preview.isEmpty ? nil : preview
}.first ?? ""
let previewCandidates = [localPath, remoteURL, thumbnailPath]
let previewURL = previewCandidates.compactMap { path -> String? in
guard !path.isEmpty else { return nil }
if path.hasPrefix("http") { return path }
let preview = CameraDownloadStorage.previewURLString(from: path)
return preview.isEmpty ? nil : preview
}.first ?? ""
return WiredTransferPhotoItem(
id: id,
sourceId: sourceId,
fileName: fileName,
thumbnailURL: thumbnailURL,
previewURL: previewURL,
capturedAt: capturedAt,
fileSizeText: Self.formatFileSize(fileSizeBytes),
status: WiredTransferUploadStatus(rawValue: status) ?? .pending,
progress: progress,
errorMessage: errorMessage
)
}
static func formatFileSize(_ bytes: Int64) -> String {
guard bytes > 0 else { return "--" }
let mb = Double(bytes) / 1024.0 / 1024.0
if mb >= 1024 {
return String(format: "%.2f GB", mb / 1024.0)
}
return String(format: "%.2f MB", mb)
}
}
extension WiredTransferPhotoItem {
///
func toRecord(
albumId: Int,
userId: String,
localPath: String,
thumbnailPath: String = "",
fileSizeBytes: Int64,
remoteURL: String = ""
) -> WiredTransferPhotoRecord {
WiredTransferPhotoRecord(
id: id,
sourceId: sourceId,
fileName: fileName,
localPath: localPath,
thumbnailPath: thumbnailPath,
capturedAt: capturedAt,
fileSizeBytes: fileSizeBytes,
status: status.rawValue,
progress: progress,
errorMessage: errorMessage,
albumId: albumId,
userId: userId,
remoteURL: remoteURL,
updatedAt: Date().timeIntervalSince1970
)
}
}
extension Array where Element == WiredTransferPhotoItem {
/// Tab 0 1 2
func filtered(byTabIndex index: Int) -> [WiredTransferPhotoItem] {
switch index {
case 1: filter { $0.status == .uploaded }
case 2: filter { $0.status == .failed }
default: self
}
}
/// Tab
var transferTabCounts: [Int] {
[count, filter { $0.status == .uploaded }.count, filter { $0.status == .failed }.count]
}
/// photo id
var selectablePendingPhotoIDs: Set<String> {
Set(filter { $0.canSelectForSpecifyUpload }.map(\.id))
}
///
func sortedByCaptureTimeDescending() -> [WiredTransferPhotoItem] {
sorted { lhs, rhs in
let leftDate = lhs.capturedDate() ?? .distantPast
let rightDate = rhs.capturedDate() ?? .distantPast
if leftDate != rightDate { return leftDate > rightDate }
return lhs.id > rhs.id
}
}
/// 30
func buildPhotoSections() -> [WiredTransferPhotoSection] {
let grouped = Dictionary(grouping: compactMap { photo -> (Date, WiredTransferPhotoItem)? in
guard let date = photo.capturedDate() else { return nil }
return (WiredTransferTimeGrouping.halfHourSlotStart(for: date), photo)
}) { $0.0 }
return grouped.keys
.sorted(by: >)
.map { slotStart in
let photos = (grouped[slotStart] ?? [])
.map(\.1)
.sorted { ($0.capturedDate() ?? .distantPast) > ($1.capturedDate() ?? .distantPast) }
return WiredTransferPhotoSection(
slotId: WiredTransferTimeGrouping.slotID(for: slotStart),
headerTitle: WiredTransferTimeGrouping.sectionHeaderTitle(for: slotStart),
photos: photos
)
}
}
///
func buildSidebarGroups() -> [WiredTransferDateGroup] {
var slotCounts: [Date: Int] = [:]
for photo in self {
guard let date = photo.capturedDate() else { continue }
let slotStart = WiredTransferTimeGrouping.halfHourSlotStart(for: date)
slotCounts[slotStart, default: 0] += 1
}
let calendar = Calendar.current
let groupedByDay = Dictionary(grouping: slotCounts.keys) { calendar.startOfDay(for: $0) }
return groupedByDay.keys
.sorted(by: >)
.map { day in
let slots = (groupedByDay[day] ?? [])
.sorted(by: >)
.compactMap { slotStart -> WiredTransferTimeSlot? in
guard let count = slotCounts[slotStart] else { return nil }
return WiredTransferTimeSlot(
id: WiredTransferTimeGrouping.slotID(for: slotStart),
dateLabel: WiredTransferTimeGrouping.dateLabel(for: slotStart),
slotStartLabel: WiredTransferTimeGrouping.timeLabel(for: slotStart),
photoCount: count
)
}
return WiredTransferDateGroup(
dateLabel: WiredTransferTimeGrouping.dateLabel(for: day),
slots: slots
)
}
}
}
/// 线 30
enum WiredTransferTimeGrouping {
/// 30
static func halfHourSlotStart(for date: Date, calendar: Calendar = .current) -> Date {
var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)
let minute = (components.minute ?? 0) / 30 * 30
components.minute = minute
components.second = 0
components.nanosecond = 0
return calendar.date(from: components) ?? date
}
///
static func slotID(for slotStart: Date) -> String {
slotIDFormatter.string(from: slotStart)
}
/// 0520 12:00 - 12:30
static func sectionHeaderTitle(for slotStart: Date) -> String {
"\(dateLabel(for: slotStart)) \(timeLabel(for: slotStart)) - \(timeLabel(for: slotStart.addingTimeInterval(30 * 60)))"
}
/// 0520
static func dateLabel(for date: Date) -> String {
dateLabelFormatter.string(from: date)
}
/// 12:05
static func timeLabel(for date: Date) -> String {
timeLabelFormatter.string(from: date)
}
private static let slotIDFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm"
return formatter
}()
private static let dateLabelFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "MM月dd日"
return formatter
}()
private static let timeLabelFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "HH:mm"
return formatter
}()
}
private extension KeyedDecodingContainer {
func lossyString(forKey key: Key) -> String {
if let value = try? decode(String.self, forKey: key) { return value }
if let value = try? decode(Int.self, forKey: key) { return "\(value)" }
if let value = try? decode(Double.self, forKey: key) { return "\(value)" }
return ""
}
func lossyInt(forKey key: Key) -> Int {
if let value = try? decode(Int.self, forKey: key) { return value }
if let value = try? decode(String.self, forKey: key), let intValue = Int(value) { return intValue }
if let value = try? decode(Double.self, forKey: key) { return Int(value) }
return 0
}
func lossyBool(forKey key: Key) -> Bool {
if let value = try? decode(Bool.self, forKey: key) { return value }
if let value = try? decode(Int.self, forKey: key) { return value != 0 }
if let value = try? decode(String.self, forKey: key) {
return value == "1" || value.lowercased() == "true"
}
return false
}
}