Files
suixinkan_ios_new/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
汉秋 c71b45bdfd 对齐有线传图页 Android UI,并改用相对路径持久化本地照片。
还原顶栏、浮动卡片、时间侧栏与底部交互;照片路径改为 CameraDownloads 相对路径并兼容旧数据;格式 Chip 固定 JPG 且不可点击。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 11:16:28 +08:00

601 lines
19 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.

//
// 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)
}
}
///
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
let thumbnailURL: 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,
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.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 previewCandidates = [thumbnailPath, localPath, remoteURL]
let thumbnailURL = 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,
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))
}
/// 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
}
}