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:
2026-06-30 09:41:05 +08:00
parent 5692134efc
commit d2fe5d71e4
48 changed files with 6477 additions and 11 deletions

View 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 ""
}
/// StringDouble 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 []
}
}