Add TravelAlbum, ProfileSpace, and wired camera transfer modules.
Introduce travel album entry with Sony PTP tethering pipeline, profile space settings page, home routing updates, Launch Screen storyboard, and related tests/docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
254
suixinkan/Features/ProfileSpace/Models/ProfileSpaceModels.swift
Normal file
254
suixinkan/Features/ProfileSpace/Models/ProfileSpaceModels.swift
Normal file
@ -0,0 +1,254 @@
|
||||
//
|
||||
// ProfileSpaceModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 摄影师个人空间配置响应,表示当前景区下的公开展示资料和接单配置。
|
||||
struct ProfileSpaceInfoResponse: Decodable, Equatable {
|
||||
let id: Int
|
||||
let scenicId: Int
|
||||
let photogUid: Int
|
||||
let realName: String
|
||||
let nickname: String
|
||||
let avatar: String
|
||||
let scenicCertification: [String]
|
||||
let description: String
|
||||
let attrLabel: [String]
|
||||
let shootLabel: [String]
|
||||
let cameraDevice: [String]
|
||||
let businessStartTime: String
|
||||
let businessEndTime: String
|
||||
let holidayStartTime: String
|
||||
let holidayEndTime: String
|
||||
let businessRange: [Int]
|
||||
let acceptOrderStatus: Int
|
||||
let schedule: [String: [ScheduleItem]]
|
||||
|
||||
/// 响应 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case scenicId = "scenic_id"
|
||||
case photogUid = "photog_uid"
|
||||
case realName = "real_name"
|
||||
case nickname
|
||||
case avatar
|
||||
case scenicCertification = "scenic_certification"
|
||||
case description
|
||||
case attrLabel = "attr_label"
|
||||
case shootLabel = "shoot_label"
|
||||
case cameraDevice = "camera_device"
|
||||
case businessStartTime = "business_start_time"
|
||||
case businessEndTime = "business_end_time"
|
||||
case holidayStartTime = "holiday_start_time"
|
||||
case holidayEndTime = "holiday_end_time"
|
||||
case businessRange = "business_range"
|
||||
case acceptOrderStatus = "accept_order_status"
|
||||
case schedule
|
||||
}
|
||||
|
||||
/// 宽松解码后端资料字段,兼容 null、数字字符串和缺失数组。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeProfileSpaceLossyInt(forKey: .id) ?? 0
|
||||
scenicId = try container.decodeProfileSpaceLossyInt(forKey: .scenicId) ?? 0
|
||||
photogUid = try container.decodeProfileSpaceLossyInt(forKey: .photogUid) ?? 0
|
||||
realName = try container.decodeProfileSpaceLossyString(forKey: .realName)
|
||||
nickname = try container.decodeProfileSpaceLossyString(forKey: .nickname)
|
||||
avatar = try container.decodeProfileSpaceLossyString(forKey: .avatar)
|
||||
scenicCertification = try container.decodeProfileSpaceStringArray(forKey: .scenicCertification)
|
||||
description = try container.decodeProfileSpaceLossyString(forKey: .description)
|
||||
attrLabel = try container.decodeProfileSpaceStringArray(forKey: .attrLabel)
|
||||
shootLabel = try container.decodeProfileSpaceStringArray(forKey: .shootLabel)
|
||||
cameraDevice = try container.decodeProfileSpaceStringArray(forKey: .cameraDevice)
|
||||
businessStartTime = try container.decodeProfileSpaceLossyString(forKey: .businessStartTime)
|
||||
businessEndTime = try container.decodeProfileSpaceLossyString(forKey: .businessEndTime)
|
||||
holidayStartTime = try container.decodeProfileSpaceLossyString(forKey: .holidayStartTime)
|
||||
holidayEndTime = try container.decodeProfileSpaceLossyString(forKey: .holidayEndTime)
|
||||
businessRange = try container.decodeProfileSpaceIntArray(forKey: .businessRange)
|
||||
acceptOrderStatus = try container.decodeProfileSpaceLossyInt(forKey: .acceptOrderStatus) ?? 0
|
||||
schedule = (try? container.decodeIfPresent([String: [ScheduleItem]].self, forKey: .schedule)) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
/// 摄影师个人空间配置更新请求。
|
||||
struct UpdateProfileSpaceRequest: Encodable, Equatable {
|
||||
let scenicId: String
|
||||
let description: String
|
||||
let cameraDevice: [String]
|
||||
let attrLabel: [String]
|
||||
let shootLabel: [String]
|
||||
let businessStartTime: String
|
||||
let businessEndTime: String
|
||||
let holidayStartTime: String
|
||||
let holidayEndTime: String
|
||||
let businessRange: [Int]
|
||||
let acceptOrderStatus: Int
|
||||
|
||||
/// 请求 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case description
|
||||
case cameraDevice = "camera_device"
|
||||
case attrLabel = "attr_label"
|
||||
case shootLabel = "shoot_label"
|
||||
case businessStartTime = "business_start_time"
|
||||
case businessEndTime = "business_end_time"
|
||||
case holidayStartTime = "holiday_start_time"
|
||||
case holidayEndTime = "holiday_end_time"
|
||||
case businessRange = "business_range"
|
||||
case acceptOrderStatus = "accept_order_status"
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人空间接单状态枚举,承接后端 1-4 状态值。
|
||||
enum ProfileSpaceOrderStatus: Int, CaseIterable, Identifiable {
|
||||
case accepting = 1
|
||||
case paused = 2
|
||||
case full = 3
|
||||
case offline = 4
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// 状态展示文案。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .accepting: "可接单"
|
||||
case .paused: "暂停接单"
|
||||
case .full: "已约满"
|
||||
case .offline: "下线"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人空间配置草稿,用于比较是否有未保存改动。
|
||||
struct ProfileSpaceEditableSnapshot: Equatable {
|
||||
var nickname: String
|
||||
var description: String
|
||||
var attrLabel: [String]
|
||||
var shootLabel: [String]
|
||||
var cameraDevice: [String]
|
||||
var businessStartTime: Date?
|
||||
var businessEndTime: Date?
|
||||
var holidayStartTime: Date?
|
||||
var holidayEndTime: Date?
|
||||
var acceptOrderStatus: Int
|
||||
var avatarURL: String
|
||||
}
|
||||
|
||||
/// 个人空间配置表单校验错误。
|
||||
enum ProfileSpaceValidationError: LocalizedError, Equatable {
|
||||
case missingScenic
|
||||
case emptyNickname
|
||||
case emptyLabel
|
||||
case duplicateLabel
|
||||
case labelTooLong
|
||||
case labelLimit
|
||||
case missingWorkdayStart
|
||||
case missingWorkdayEnd
|
||||
case missingHolidayStart
|
||||
case missingHolidayEnd
|
||||
case invalidWorkdayTime
|
||||
case invalidHolidayTime
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingScenic: "当前缺少景区信息"
|
||||
case .emptyNickname: "请输入昵称"
|
||||
case .emptyLabel: "标签不能为空"
|
||||
case .duplicateLabel: "标签已存在"
|
||||
case .labelTooLong: "标签最多20个字符"
|
||||
case .labelLimit: "最多8个标签"
|
||||
case .missingWorkdayStart: "请输入工作日开始时间"
|
||||
case .missingWorkdayEnd: "请输入工作日结束时间"
|
||||
case .missingHolidayStart: "请输入节假日开始时间"
|
||||
case .missingHolidayEnd: "请输入节假日结束时间"
|
||||
case .invalidWorkdayTime: "工作日开始时间必须早于结束时间"
|
||||
case .invalidHolidayTime: "节假日开始时间必须早于结束时间"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
/// 将日期格式化为后端需要的 HH:mm:ss 时间文本。
|
||||
var profileSpaceTimeText: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
/// 将日期格式化为空间配置页面展示的 HH:mm 文本。
|
||||
var profileSpaceHourMinuteText: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "HH:mm"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
/// 根据 HH:mm:ss 或 HH:mm 文本生成仅含时间部分的 Date。
|
||||
static func profileSpaceTime(from text: String) -> Date? {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, trimmed != "00:00:00" else { return nil }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = trimmed.count == 5 ? "HH:mm" : "HH:mm:ss"
|
||||
return formatter.date(from: trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||
func decodeProfileSpaceLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "1" : "0"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 String、Double 和 Int 宽松解码为整数。
|
||||
func decodeProfileSpaceLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Int(text) ?? Double(text).map(Int.init)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 宽松解码字符串数组,过滤空白项。
|
||||
func decodeProfileSpaceStringArray(forKey key: Key) throws -> [String] {
|
||||
guard let values = try? decodeIfPresent([String].self, forKey: key) else { return [] }
|
||||
return values
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
/// 宽松解码整数数组。
|
||||
func decodeProfileSpaceIntArray(forKey key: Key) throws -> [Int] {
|
||||
if let values = try? decodeIfPresent([Int].self, forKey: key) {
|
||||
return values
|
||||
}
|
||||
if let values = try? decodeIfPresent([String].self, forKey: key) {
|
||||
return values.compactMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user