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

@ -21,6 +21,7 @@ struct HomeMenuItem: Equatable, Identifiable {
///
enum HomeRoute: Hashable {
case profileSpace
case profileSpaceSettings
case scenicSelection
case permissionApply
case permissionApplyStatus
@ -63,6 +64,7 @@ enum HomeRoute: Hashable {
case queueManagement
case liveManagement
case liveAlbum
case travelAlbumEntry
case operatingArea
case pilotCertification
case modulePlaceholder(uri: String, title: String)

View File

@ -35,6 +35,7 @@ enum HomeMenuRouter {
"sample_upload": "上传样片",
"live_stream_management": "直播管理",
"live_album": "直播相册",
"travel_album": "新增相册",
"scenicselection": "景区选择",
"scenicapplication": "景区申请",
"permission_apply": "权限申请",
@ -76,7 +77,9 @@ enum HomeMenuRouter {
return .orderPush(.writeOffList)
case "photographer_stats":
return .tab(.statistics)
case "space_settings", "basic_info":
case "space_settings":
return .destination(.profileSpaceSettings)
case "basic_info":
return .destination(.profileSpace)
case "system_settings":
return .destination(.settings)
@ -146,6 +149,8 @@ enum HomeMenuRouter {
return .destination(.liveManagement)
case "live_album":
return .destination(.liveAlbum)
case "travel_album", "task_new_entry":
return .destination(.travelAlbumEntry)
case "operating-area":
return .destination(.operatingArea)
case "pilot_cert":
@ -176,6 +181,7 @@ enum HomeMenuRouter {
"basic_info",
"album_list",
"album_trailer",
"travel_album",
"wallet",
"payment_collection",
"payment_qr",

View File

@ -48,6 +48,8 @@ enum HomeIconCatalog {
"checkmark.seal.fill"
case "live_album":
"play.rectangle.on.rectangle.fill"
case "travel_album", "task_new_entry":
"photo.on.rectangle.angled"
case "pm", "pm_manager", "project_edit":
"circle.grid.2x2.fill"
case "location_report", "location_report_history":

View File

@ -14,6 +14,8 @@ extension HomeRoute {
switch self {
case .profileSpace:
ProfileView()
case .profileSpaceSettings:
ProfileSpaceView()
case .scenicSelection:
ScenicSelectionView()
case .permissionApply:
@ -102,6 +104,8 @@ extension HomeRoute {
LiveManagementView()
case .liveAlbum:
LiveAlbumView()
case .travelAlbumEntry:
TravelAlbumEntryView()
case .operatingArea:
OperatingAreaView()
case .pilotCertification:

View File

@ -8,6 +8,22 @@
import Foundation
import Combine
@MainActor
/// API 便 ViewModel 使
protocol ProfileSpaceServing {
///
func profileSpaceInfo(scenicId: Int) async throws -> ProfileSpaceInfoResponse
///
func updateProfileSpaceInfo(_ request: UpdateProfileSpaceRequest) async throws
///
func updateUserInfo(nickname: String?, password: String?, avatar: String?) async throws
/// URL
func updateUserAvatarURL(_ fileURL: String) async throws
}
@MainActor
/// API
final class ProfileAPI {
@ -71,6 +87,28 @@ final class ProfileAPI {
)
}
///
func profileSpaceInfo(scenicId: Int) async throws -> ProfileSpaceInfoResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/profile/info",
queryItems: [URLQueryItem(name: "scenic_id", value: "\(scenicId)")]
)
)
}
///
func updateProfileSpaceInfo(_ request: UpdateProfileSpaceRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/profile/info-update",
body: request
)
)
}
///
func realNameSmsVerifyCode() async throws {
let _: EmptyPayload = try await client.send(
@ -95,3 +133,4 @@ final class ProfileAPI {
}
extension ProfileAPI: UserProfileServing {}
extension ProfileAPI: ProfileSpaceServing {}

View File

@ -4,6 +4,8 @@
Profile 模块负责“我的/个人信息”页面及其二级页面,包括用户资料展示、昵称编辑、账号切换、密码修改、实名认证、系统设置、协议页和退出登录。
首页“空间设置”入口对应的是摄影师当前景区下的个人空间配置,已拆到 `ProfileSpace` 模块;`Profile` 模块只保留账号级个人资料能力。
该模块聚焦个人资料业务:
- 拉取用户基础资料。
- 拉取实名认证信息。
@ -35,6 +37,8 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,
- `UpdateInfoRequest`:昵称、密码或头像更新请求体。
- `RealNameAuthRequest`:实名认证提交请求体。
`basic_info` 首页权限入口继续进入 `ProfileView``space_settings` 进入 `ProfileSpaceView`,两者不共用页面状态。
## 加载流程
1. `ProfileView.task` 进入页面时调用 `reloadProfile()`

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 []
}
}

View File

@ -0,0 +1,58 @@
# ProfileSpace 模块业务逻辑
## 模块职责
ProfileSpace 模块负责首页“空间设置”入口,对齐 Android `space_settings -> ProfileInfoRoute` 的个人空间配置能力。
该模块聚焦摄影师在当前景区下的公开展示和接单配置:
- 拉取个人空间资料。
- 修改头像、昵称和简介。
- 管理个人标签、拍摄说明标签和相机设备。
- 配置工作日与法定节假日营业时间。
- 配置接单状态。
- 展示未来 7 天日程,并支持添加、删除和拨打客户电话。
相册管理不属于本模块,仍由 Assets/相册模块承接。
## 核心对象
- `ProfileSpaceView`:空间配置页 UI负责资料展示、表单输入、日程展示和保存入口。
- `ProfileSpaceViewModel`:维护编辑草稿、原始快照、脏状态、校验和提交逻辑。
- `ProfileSpaceInfoResponse`:空间配置详情响应。
- `UpdateProfileSpaceRequest`:空间配置更新请求。
- `ProfileAPI`:封装空间配置读取和更新接口,同时复用用户昵称、头像更新接口。
- `ScheduleAPI`:复用已有排班新增和删除接口。
- `OSSUploadService`:复用头像上传能力。
## 加载流程
1. 首页点击 `space_settings` 后进入 `ProfileSpaceView`
2. 页面从 `AccountContext.currentScenic` 读取当前景区 ID。
3. `ProfileSpaceViewModel.load` 调用 `ProfileAPI.profileSpaceInfo`
4. 接口成功后回填头像、姓名、昵称、简介、标签、设备、营业时间、接单状态和日程 map。
5. ViewModel 保存一份 `ProfileSpaceEditableSnapshot`,用于判断保存按钮是否可用。
缺少当前景区时,页面展示缺少景区空态,不发起空间配置接口。
## 保存流程
1. 用户修改表单后ViewModel 根据当前草稿和原始快照判断 `hasChanges`
2. 点击保存时先校验:
- 昵称不能为空。
- 标签和拍摄说明均最多 8 个,单个最多 20 字,禁止空值和重复。
- 四个营业时间必须填写。
- 工作日、节假日开始时间必须早于结束时间。
3. 如头像有变化,先调用 `OSSUploadService.uploadUserAvatar` 上传,再调用 `ProfileAPI.updateUserAvatarURL` 回写头像。
4. 如昵称有变化,调用 `ProfileAPI.updateUserInfo(nickname:)`
5. 调用 `ProfileAPI.updateProfileSpaceInfo` 保存空间配置。
6. 保存成功后刷新原始快照,清除头像暂存,并同步 `AccountContext` 和账号快照中的昵称/头像展示信息。
保存失败时保留当前草稿和未保存状态,页面展示错误信息。
## 日程流程
空间配置页展示从空间详情接口返回的 `schedule` map并在页面内显示从今天起连续 7 天的日程。
添加日程复用现有 `ScheduleAddView`,提交逻辑仍由 Schedule 模块负责。页面返回后会重新加载空间配置。
删除日程调用 `ScheduleAPI.deleteSchedule`,成功后重新加载空间配置,保持日程来源一致。

View File

@ -0,0 +1,302 @@
//
// ProfileSpaceViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
import Combine
@MainActor
/// ViewModel稿
final class ProfileSpaceViewModel: ObservableObject {
@Published private(set) var loading = false
@Published private(set) var saving = false
@Published var errorMessage: String?
@Published var nickname = ""
@Published private(set) var realName = ""
@Published private(set) var avatarURL = ""
@Published private(set) var scenicCertification: [String] = []
@Published var introduction = ""
@Published var attrLabels: [String] = []
@Published var shootLabels: [String] = []
@Published var cameraDevices: [String] = []
@Published var businessStartTime: Date?
@Published var businessEndTime: Date?
@Published var holidayStartTime: Date?
@Published var holidayEndTime: Date?
@Published var acceptOrderStatus = 0
@Published private(set) var scheduleMap: [String: [ScheduleItem]] = [:]
@Published private(set) var pendingAvatarData: Data?
@Published private(set) var pendingAvatarFileName: String?
@Published private(set) var avatarUploadProgress: Int?
private var originalSnapshot: ProfileSpaceEditableSnapshot?
///
var hasChanges: Bool {
guard let originalSnapshot else { return pendingAvatarData != nil }
return editableSnapshot() != originalSnapshot || pendingAvatarData != nil
}
/// 使
func load(api: any ProfileSpaceServing, scenicId: Int?) async {
guard let scenicId else {
resetForMissingScenic()
return
}
guard !loading else { return }
loading = true
errorMessage = nil
defer { loading = false }
do {
let response = try await api.profileSpaceInfo(scenicId: scenicId)
apply(response)
} catch {
errorMessage = error.localizedDescription
}
}
///
func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp)
pendingAvatarData = processed.data
pendingAvatarFileName = processed.fileName
}
///
func addAttrLabel(_ label: String) throws {
attrLabels = try adding(label, to: attrLabels)
}
///
func addShootLabel(_ label: String) throws {
shootLabels = try adding(label, to: shootLabels)
}
///
func removeAttrLabel(_ label: String) {
attrLabels.removeAll { $0 == label }
}
///
func removeShootLabel(_ label: String) {
shootLabels.removeAll { $0 == label }
}
///
func addCameraDevice(_ device: String) {
let value = device.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
cameraDevices.append(value)
}
///
func removeCameraDevice(_ device: String) {
cameraDevices.removeAll { $0 == device }
}
///
func save(api: any ProfileSpaceServing, uploader: any OSSUploadServing, scenicId: Int?) async throws {
guard let scenicId else {
errorMessage = ProfileSpaceValidationError.missingScenic.localizedDescription
throw ProfileSpaceValidationError.missingScenic
}
do {
let request = try makeUpdateRequest(scenicId: scenicId)
guard !saving else { return }
saving = true
errorMessage = nil
avatarUploadProgress = pendingAvatarData == nil ? nil : 1
defer {
saving = false
avatarUploadProgress = nil
}
let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId)
if let uploadedAvatarURL {
try await api.updateUserAvatarURL(uploadedAvatarURL)
}
let nextNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
if nextNickname != originalSnapshot?.nickname {
try await api.updateUserInfo(nickname: nextNickname, password: nil, avatar: nil)
}
try await api.updateProfileSpaceInfo(request)
if let uploadedAvatarURL {
avatarURL = uploadedAvatarURL
}
nickname = nextNickname
pendingAvatarData = nil
pendingAvatarFileName = nil
originalSnapshot = editableSnapshot()
} catch {
errorMessage = error.localizedDescription
throw error
}
}
///
func deleteSchedule(
item: ScheduleItem,
profileAPI: any ProfileSpaceServing,
scheduleAPI: any ScheduleServing,
scenicId: Int?
) async {
guard let scenicId else {
errorMessage = ProfileSpaceValidationError.missingScenic.localizedDescription
return
}
do {
try await scheduleAPI.deleteSchedule(id: item.id)
await load(api: profileAPI, scenicId: scenicId)
} catch {
errorMessage = error.localizedDescription
}
}
/// AccountContext
func accountProfileFallback(_ fallback: AccountProfile?) -> AccountProfile? {
AccountProfile(
userId: fallback?.userId ?? "",
displayName: nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? (fallback?.displayName ?? "") : nickname,
phone: fallback?.phone,
avatarURL: avatarURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fallback?.avatarURL : avatarURL
)
}
///
func makeUpdateRequest(scenicId: Int) throws -> UpdateProfileSpaceRequest {
let nextNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
guard !nextNickname.isEmpty else {
throw ProfileSpaceValidationError.emptyNickname
}
guard let businessStartTime else {
throw ProfileSpaceValidationError.missingWorkdayStart
}
guard let businessEndTime else {
throw ProfileSpaceValidationError.missingWorkdayEnd
}
guard let holidayStartTime else {
throw ProfileSpaceValidationError.missingHolidayStart
}
guard let holidayEndTime else {
throw ProfileSpaceValidationError.missingHolidayEnd
}
guard seconds(in: businessStartTime) < seconds(in: businessEndTime) else {
throw ProfileSpaceValidationError.invalidWorkdayTime
}
guard seconds(in: holidayStartTime) < seconds(in: holidayEndTime) else {
throw ProfileSpaceValidationError.invalidHolidayTime
}
return UpdateProfileSpaceRequest(
scenicId: "\(scenicId)",
description: introduction.trimmingCharacters(in: .whitespacesAndNewlines),
cameraDevice: normalized(cameraDevices),
attrLabel: normalized(attrLabels),
shootLabel: normalized(shootLabels),
businessStartTime: businessStartTime.profileSpaceTimeText,
businessEndTime: businessEndTime.profileSpaceTimeText,
holidayStartTime: holidayStartTime.profileSpaceTimeText,
holidayEndTime: holidayEndTime.profileSpaceTimeText,
businessRange: [],
acceptOrderStatus: acceptOrderStatus
)
}
///
private func apply(_ response: ProfileSpaceInfoResponse) {
realName = response.realName
nickname = response.nickname
avatarURL = response.avatar
scenicCertification = response.scenicCertification
introduction = response.description
attrLabels = response.attrLabel
shootLabels = response.shootLabel
cameraDevices = response.cameraDevice
businessStartTime = Date.profileSpaceTime(from: response.businessStartTime)
businessEndTime = Date.profileSpaceTime(from: response.businessEndTime)
holidayStartTime = Date.profileSpaceTime(from: response.holidayStartTime)
holidayEndTime = Date.profileSpaceTime(from: response.holidayEndTime)
acceptOrderStatus = response.acceptOrderStatus
scheduleMap = response.schedule
pendingAvatarData = nil
pendingAvatarFileName = nil
originalSnapshot = editableSnapshot()
}
///
private func resetForMissingScenic() {
loading = false
errorMessage = ProfileSpaceValidationError.missingScenic.localizedDescription
realName = ""
nickname = ""
avatarURL = ""
scenicCertification = []
introduction = ""
attrLabels = []
shootLabels = []
cameraDevices = []
businessStartTime = nil
businessEndTime = nil
holidayStartTime = nil
holidayEndTime = nil
acceptOrderStatus = 0
scheduleMap = [:]
originalSnapshot = nil
}
///
private func editableSnapshot() -> ProfileSpaceEditableSnapshot {
ProfileSpaceEditableSnapshot(
nickname: nickname.trimmingCharacters(in: .whitespacesAndNewlines),
description: introduction.trimmingCharacters(in: .whitespacesAndNewlines),
attrLabel: normalized(attrLabels),
shootLabel: normalized(shootLabels),
cameraDevice: normalized(cameraDevices),
businessStartTime: businessStartTime,
businessEndTime: businessEndTime,
holidayStartTime: holidayStartTime,
holidayEndTime: holidayEndTime,
acceptOrderStatus: acceptOrderStatus,
avatarURL: avatarURL
)
}
///
private func adding(_ label: String, to labels: [String]) throws -> [String] {
let value = label.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { throw ProfileSpaceValidationError.emptyLabel }
guard value.count <= 20 else { throw ProfileSpaceValidationError.labelTooLong }
guard labels.count < 8 else { throw ProfileSpaceValidationError.labelLimit }
guard !labels.contains(value) else { throw ProfileSpaceValidationError.duplicateLabel }
return labels + [value]
}
///
private func normalized(_ values: [String]) -> [String] {
values
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
///
private func seconds(in date: Date) -> Int {
let components = Calendar.current.dateComponents([.hour, .minute, .second], from: date)
return (components.hour ?? 0) * 3600 + (components.minute ?? 0) * 60 + (components.second ?? 0)
}
/// nil
private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? {
guard let pendingAvatarData else { return nil }
let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg"
return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in
Task { @MainActor in
self?.avatarUploadProgress = progress
}
}
}
}

View File

@ -0,0 +1,733 @@
//
// ProfileSpaceView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import PhotosUI
import SwiftUI
import UIKit
///
struct ProfileSpaceView: View {
@EnvironmentObject private var accountContext: AccountContext
@EnvironmentObject private var router: RouterPath
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.profileAPI) private var profileAPI
@Environment(\.scheduleAPI) private var scheduleAPI
@Environment(\.ossUploadService) private var ossUploadService
@Environment(\.accountSnapshotStore) private var snapshotStore
@Environment(\.globalLoading) private var globalLoading
@Environment(\.openURL) private var openURL
@StateObject private var viewModel = ProfileSpaceViewModel()
@State private var pickedAvatarItem: PhotosPickerItem?
@State private var attrInput = ""
@State private var shootInput = ""
@State private var deviceInput = ""
@State private var selectedDate = Date()
@State private var deleteTarget: ProfileSpaceDeleteTarget?
@State private var appearedOnce = false
private var scenicId: Int? {
accountContext.currentScenic?.id
}
private var selectedDateKey: String {
selectedDate.scheduleDayText
}
private var selectedSchedules: [ScheduleItem] {
viewModel.scheduleMap[selectedDateKey] ?? []
}
var body: some View {
Group {
if scenicId == nil {
AppContentUnavailableView("缺少景区", systemImage: "person.crop.circle.badge.exclamationmark", description: Text("请选择景区后再配置个人空间。"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
} else {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
profileCard
labelCard
businessTimeCard
deviceCard
orderStatusCard
scheduleCard
}
.padding(AppMetrics.Spacing.medium)
.padding(.bottom, 78)
}
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.refreshable { await reload(showLoading: false) }
.safeAreaInset(edge: .bottom) {
saveBar
}
}
}
.navigationTitle("个人空间配置")
.navigationBarTitleDisplayMode(.inline)
.task(id: scenicId) {
await reload(showLoading: true)
}
.onAppear {
guard appearedOnce else {
appearedOnce = true
return
}
Task { await reload(showLoading: false) }
}
.onChange(of: pickedAvatarItem) { item in
Task { await prepareAvatarImage(from: item) }
}
.alert(item: $deleteTarget) { target in
Alert(
title: Text(target.title),
message: Text(target.message),
primaryButton: .destructive(Text("删除")) {
delete(target)
},
secondaryButton: .cancel(Text("取消"))
)
}
}
private var profileCard: some View {
sectionCard {
HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) {
avatarPicker
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("姓名: \(viewModel.realName.isEmpty ? "--" : viewModel.realName)")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
TextField("请输入昵称", text: $viewModel.nickname)
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.appInputFieldStyle(cornerRadius: 8, minHeight: 42)
TextField("请输入简介", text: $viewModel.introduction, axis: .vertical)
.lineLimit(2...5)
.font(.system(size: AppMetrics.FontSize.subheadline))
.appInputFieldStyle(cornerRadius: 8, minHeight: 78)
certificationTags
}
}
}
}
private var avatarPicker: some View {
PhotosPicker(selection: $pickedAvatarItem, matching: .images) {
avatarPreview
.frame(width: 92, height: 92)
.clipShape(Circle())
.overlay {
Circle().stroke(.white, lineWidth: 3)
}
.overlay(alignment: .bottomTrailing) {
Image(systemName: "camera.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 26, height: 26)
.background(AppDesign.primary, in: Circle())
.overlay { Circle().stroke(.white, lineWidth: 2) }
}
.overlay {
if let progress = viewModel.avatarUploadProgress {
ZStack {
Circle().fill(.black.opacity(0.34))
Text("\(progress)%")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(.white)
}
}
}
}
.buttonStyle(.plain)
.accessibilityLabel("头像")
}
@ViewBuilder
private var avatarPreview: some View {
if let data = viewModel.pendingAvatarData, let image = UIImage(data: data) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
RemoteAvatarImage(urlString: viewModel.avatarURL)
}
}
@ViewBuilder
private var certificationTags: some View {
if !viewModel.scenicCertification.isEmpty {
FlowLayout(spacing: 6, lineSpacing: 6) {
ForEach(viewModel.scenicCertification, id: \.self) { tag in
Text(tag)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.warning)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color(hex: 0xFFF0E2), in: RoundedRectangle(cornerRadius: 4))
}
}
}
}
private var labelCard: some View {
sectionCard {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("标签")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
chipEditor(labels: viewModel.attrLabels, input: $attrInput, placeholder: "添加标签") { value in
try viewModel.addAttrLabel(value)
} remove: { value in
deleteTarget = .attrLabel(value)
}
Text("拍摄说明")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
.padding(.top, AppMetrics.Spacing.xSmall)
chipEditor(labels: viewModel.shootLabels, input: $shootInput, placeholder: "添加拍摄说明") { value in
try viewModel.addShootLabel(value)
} remove: { value in
deleteTarget = .shootLabel(value)
}
Text("每个标签最多20个字符,最多8个标签")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.placeholder)
}
}
}
private var businessTimeCard: some View {
sectionCard {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
sectionTitle("营业时间")
timeRow(title: "工作日营业时间", start: $viewModel.businessStartTime, end: $viewModel.businessEndTime)
timeRow(title: "法定节假日营业时间", start: $viewModel.holidayStartTime, end: $viewModel.holidayEndTime)
}
}
}
private var deviceCard: some View {
sectionCard {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
sectionTitle("相机设备")
FlowLayout(spacing: 8, lineSpacing: 8) {
ForEach(viewModel.cameraDevices, id: \.self) { device in
removableChip(title: device, tint: AppDesign.textSecondary) {
deleteTarget = .device(device)
}
}
}
HStack(spacing: AppMetrics.Spacing.small) {
TextField("请输入设备名", text: $deviceInput)
.appInputFieldStyle(cornerRadius: 8, minHeight: 42)
Button {
viewModel.addCameraDevice(deviceInput)
deviceInput = ""
} label: {
Image(systemName: "plus")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.frame(width: 44, height: 42)
}
.buttonStyle(.borderedProminent)
.disabled(deviceInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
private var orderStatusCard: some View {
sectionCard {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
sectionTitle("接单状态")
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: AppMetrics.Spacing.small), count: 2), spacing: AppMetrics.Spacing.small) {
ForEach(ProfileSpaceOrderStatus.allCases) { status in
Button {
viewModel.acceptOrderStatus = status.rawValue
} label: {
Text(status.title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(viewModel.acceptOrderStatus == status.rawValue ? .white : AppDesign.textSecondary)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(
viewModel.acceptOrderStatus == status.rawValue ? AppDesign.primary : Color.white,
in: RoundedRectangle(cornerRadius: 8)
)
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(viewModel.acceptOrderStatus == status.rawValue ? AppDesign.primary : Color(hex: 0xD7DEE8), lineWidth: 1)
}
}
.buttonStyle(.plain)
}
}
}
}
}
private var scheduleCard: some View {
sectionCard {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
HStack {
sectionTitle("日程安排")
Spacer()
NavigationLink(value: AppRoute.home(.scheduleAdd)) {
Label("添加日程", systemImage: "plus")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
}
}
weekSelector
HStack {
Text(selectedDate.scheduleDayText)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
}
if selectedSchedules.isEmpty {
AppContentUnavailableView("该日无日程", systemImage: "calendar")
.frame(minHeight: 120)
} else {
VStack(spacing: AppMetrics.Spacing.small) {
ForEach(selectedSchedules) { item in
scheduleItemCard(item)
}
}
}
}
}
}
private var weekSelector: some View {
HStack(spacing: 0) {
ForEach(nextSevenDays, id: \.scheduleDayText) { date in
Button {
selectedDate = date
} label: {
VStack(spacing: AppMetrics.Spacing.xSmall) {
Text(weekdayText(for: date))
.font(.system(size: AppMetrics.FontSize.caption))
Text(dayText(for: date))
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.frame(width: 34, height: 34)
.background(isSelected(date) ? AppDesign.primary : Color(hex: 0xF3F5F8), in: RoundedRectangle(cornerRadius: 8))
.foregroundStyle(isSelected(date) ? .white : AppDesign.textSecondary)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
}
}
}
private var saveBar: some View {
VStack(spacing: 0) {
if let error = viewModel.errorMessage {
Text(error)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(Color(hex: 0xE5484D))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, AppMetrics.Spacing.medium)
.padding(.top, AppMetrics.Spacing.small)
}
Button {
Task { await save() }
} label: {
HStack {
if viewModel.saving {
ProgressView()
.tint(.white)
}
Text(viewModel.saving ? "保存中..." : "保存")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
.background(viewModel.hasChanges && !viewModel.saving ? AppDesign.primary : Color(hex: 0xB6BECA), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
}
.buttonStyle(.plain)
.disabled(!viewModel.hasChanges || viewModel.saving)
.padding(AppMetrics.Spacing.medium)
}
.background(.white)
.overlay(alignment: .top) {
Rectangle()
.fill(Color(hex: 0xE5E7EB))
.frame(height: 0.5)
}
}
///
private func sectionCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 0) {
content()
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private func sectionTitle(_ title: String) -> some View {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
}
///
private func chipEditor(
labels: [String],
input: Binding<String>,
placeholder: String,
add: @escaping (String) throws -> Void,
remove: @escaping (String) -> Void
) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
FlowLayout(spacing: 8, lineSpacing: 8) {
ForEach(labels, id: \.self) { label in
removableChip(title: label, tint: AppDesign.primary) {
remove(label)
}
}
}
HStack(spacing: AppMetrics.Spacing.small) {
TextField(placeholder, text: input)
.appInputFieldStyle(cornerRadius: 8, minHeight: 42)
Button {
do {
try add(input.wrappedValue)
input.wrappedValue = ""
} catch {
viewModel.errorMessage = error.localizedDescription
}
} label: {
Text("添加")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.frame(width: 56, height: 42)
}
.buttonStyle(.borderedProminent)
.disabled(input.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
///
private func removableChip(title: String, tint: Color, onRemove: @escaping () -> Void) -> some View {
HStack(spacing: 4) {
Text(title)
.font(.system(size: AppMetrics.FontSize.caption))
Button(action: onRemove) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .bold))
}
.buttonStyle(.plain)
}
.foregroundStyle(tint)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 5))
}
///
private func timeRow(title: String, start: Binding<Date?>, end: Binding<Date?>) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
HStack(spacing: AppMetrics.Spacing.small) {
optionalTimePicker("开始", selection: start)
Text("")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
optionalTimePicker("结束", selection: end)
}
}
}
///
private func optionalTimePicker(_ title: String, selection: Binding<Date?>) -> some View {
VStack(alignment: .leading, spacing: 4) {
DatePicker(
title,
selection: Binding(
get: { selection.wrappedValue ?? defaultTime },
set: { selection.wrappedValue = $0 }
),
displayedComponents: .hourAndMinute
)
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
Text(selection.wrappedValue?.profileSpaceHourMinuteText ?? "未设置")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(selection.wrappedValue == nil ? Color(hex: 0xE5484D) : AppDesign.textSecondary)
}
}
///
private func scheduleItemCard(_ item: ScheduleItem) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack(spacing: AppMetrics.Spacing.small) {
Text("\(item.startTime) - \(item.endTime) \(item.name)")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
Spacer()
Button(role: .destructive) {
deleteTarget = .schedule(item)
} label: {
Text("删除")
.font(.system(size: AppMetrics.FontSize.subheadline))
}
}
if let orderNumber = item.orderNumber, !orderNumber.isEmpty {
scheduleText("订单号: \(orderNumber)")
}
if let phone = item.userPhone, !phone.isEmpty {
HStack(spacing: AppMetrics.Spacing.medium) {
scheduleText("客户手机号: \(maskedPhone(phone))")
Button("拨打") {
if let url = URL(string: "tel:\(phone)") {
openURL(url)
}
}
.font(.system(size: AppMetrics.FontSize.subheadline))
}
}
if !item.remark.isEmpty {
scheduleText("备注信息: \(item.remark)")
}
}
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
}
///
private func scheduleText(_ text: String) -> some View {
Text(text)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(2)
}
///
private func reload(showLoading: Bool) async {
await globalLoading.withOptionalLoading(showLoading && scenicId != nil) {
await viewModel.load(api: profileAPI, scenicId: scenicId)
}
}
/// PhotosPicker
private func prepareAvatarImage(from item: PhotosPickerItem?) async {
guard let item else { return }
do {
guard let data = try await item.loadTransferable(type: Data.self) else { return }
try viewModel.prepareAvatarImage(data: data)
} catch {
viewModel.errorMessage = error.localizedDescription
}
}
///
private func save() async {
do {
try await globalLoading.withLoading {
try await viewModel.save(api: profileAPI, uploader: ossUploadService, scenicId: scenicId)
}
if let profile = viewModel.accountProfileFallback(accountContext.profile) {
accountContext.replaceProfile(profile)
saveSnapshotProfile(profile)
}
toastCenter.show("保存成功")
} catch {
viewModel.errorMessage = error.localizedDescription
toastCenter.show(error.localizedDescription)
}
}
///
private func delete(_ target: ProfileSpaceDeleteTarget) {
switch target {
case .attrLabel(let label):
viewModel.removeAttrLabel(label)
case .shootLabel(let label):
viewModel.removeShootLabel(label)
case .device(let device):
viewModel.removeCameraDevice(device)
case .schedule(let item):
Task {
await globalLoading.withLoading {
await viewModel.deleteSchedule(item: item, profileAPI: profileAPI, scheduleAPI: scheduleAPI, scenicId: scenicId)
}
}
}
}
///
private func saveSnapshotProfile(_ profile: AccountProfile) {
let existing = snapshotStore.load()
snapshotStore.save(
AccountSnapshot(
profile: profile,
accountType: accountContext.accountType ?? existing?.accountType,
businessUserId: existing?.businessUserId,
currentRoleCode: existing?.currentRoleCode,
scenicScopes: accountContext.scenicScopes,
storeScopes: accountContext.storeScopes,
currentScenicId: accountContext.currentScenic?.id,
currentStoreId: accountContext.currentStore?.id
)
)
}
private var defaultTime: Date {
Calendar.current.date(from: DateComponents(hour: 9, minute: 0)) ?? Date()
}
private var nextSevenDays: [Date] {
(0..<7).compactMap { Calendar.current.date(byAdding: .day, value: $0, to: Date()) }
}
private func isSelected(_ date: Date) -> Bool {
date.scheduleDayText == selectedDate.scheduleDayText
}
private func weekdayText(for date: Date) -> String {
let symbols = ["", "", "", "", "", "", ""]
let index = Calendar.current.component(.weekday, from: date) - 1
return symbols[max(0, min(index, symbols.count - 1))]
}
private func dayText(for date: Date) -> String {
"\(Calendar.current.component(.day, from: date))"
}
private func maskedPhone(_ phone: String) -> String {
guard phone.count >= 11 else { return phone }
let prefix = phone.prefix(3)
let suffix = phone.suffix(4)
return "\(prefix)****\(suffix)"
}
}
///
private enum ProfileSpaceDeleteTarget: Identifiable {
case attrLabel(String)
case shootLabel(String)
case device(String)
case schedule(ScheduleItem)
var id: String {
switch self {
case .attrLabel(let label): "attr-\(label)"
case .shootLabel(let label): "shoot-\(label)"
case .device(let device): "device-\(device)"
case .schedule(let item): "schedule-\(item.id)"
}
}
var title: String {
switch self {
case .attrLabel: "删除标签"
case .shootLabel: "删除拍摄说明"
case .device: "删除设备"
case .schedule: "删除日程"
}
}
var message: String {
switch self {
case .attrLabel(let label):
"您确定要删除\(label)标签吗?"
case .shootLabel(let label):
"您确定要删除\(label)拍摄说明吗?"
case .device(let device):
"您确定要删除\(device)设备吗?"
case .schedule:
"您确定要删除该日程吗?"
}
}
}
///
private struct FlowLayout: Layout {
let spacing: CGFloat
let lineSpacing: CGFloat
///
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxWidth = proposal.width ?? 0
let rows = makeRows(maxWidth: maxWidth, subviews: subviews)
let width = rows.map(\.width).max() ?? 0
let height = rows.reduce(CGFloat.zero) { $0 + $1.height } + lineSpacing * CGFloat(max(0, rows.count - 1))
return CGSize(width: width, height: height)
}
///
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let rows = makeRows(maxWidth: bounds.width, subviews: subviews)
var y = bounds.minY
for row in rows {
var x = bounds.minX
for item in row.items {
subviews[item.index].place(
at: CGPoint(x: x, y: y),
proposal: ProposedViewSize(width: item.size.width, height: item.size.height)
)
x += item.size.width + spacing
}
y += row.height + lineSpacing
}
}
///
private func makeRows(maxWidth: CGFloat, subviews: Subviews) -> [FlowRow] {
guard maxWidth > 0 else {
return subviews.enumerated().map { index, subview in
let size = subview.sizeThatFits(.unspecified)
return FlowRow(items: [FlowItem(index: index, size: size)], width: size.width, height: size.height)
}
}
var rows: [FlowRow] = []
var currentItems: [FlowItem] = []
var currentWidth: CGFloat = 0
var currentHeight: CGFloat = 0
for (index, subview) in subviews.enumerated() {
let size = subview.sizeThatFits(.unspecified)
let nextWidth = currentItems.isEmpty ? size.width : currentWidth + spacing + size.width
if nextWidth > maxWidth, !currentItems.isEmpty {
rows.append(FlowRow(items: currentItems, width: currentWidth, height: currentHeight))
currentItems = [FlowItem(index: index, size: size)]
currentWidth = size.width
currentHeight = size.height
} else {
currentItems.append(FlowItem(index: index, size: size))
currentWidth = nextWidth
currentHeight = max(currentHeight, size.height)
}
}
if !currentItems.isEmpty {
rows.append(FlowRow(items: currentItems, width: currentWidth, height: currentHeight))
}
return rows
}
private struct FlowItem {
let index: Int
let size: CGSize
}
private struct FlowRow {
let items: [FlowItem]
let width: CGFloat
let height: CGFloat
}
}

View File

@ -0,0 +1,130 @@
//
// TravelAlbumAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
private let travelAlbumBase = "/api/yf-handset-app/photog/travel-album"
/// 便 ViewModel
@MainActor
protocol TravelAlbumServing {
func availableOrders() async throws -> [TravelAlbumAvailableOrder]
func createAlbum(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse
func albumList(page: Int, pageSize: Int) async throws -> ListPayload<TravelAlbumItem>
func albumInfo(id: Int) async throws -> TravelAlbumItem
func editAlbum(_ request: TravelAlbumEditRequest) async throws
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws
func materialList(userEquityTravelId: Int, page: Int, pageSize: Int) async throws -> ListPayload<TravelAlbumMaterial>
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
}
/// API travel-album
@MainActor
final class TravelAlbumAPI: TravelAlbumServing {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
try await client.send(
APIRequest(method: .get, path: "\(travelAlbumBase)/available-order")
)
}
///
func createAlbum(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse {
try await client.send(
APIRequest(method: .post, path: "\(travelAlbumBase)/create", body: request)
)
}
///
func albumList(page: Int, pageSize: Int) async throws -> ListPayload<TravelAlbumItem> {
try await client.send(
APIRequest(
method: .get,
path: "\(travelAlbumBase)/list",
queryItems: [
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
)
)
}
///
func albumInfo(id: Int) async throws -> TravelAlbumItem {
try await client.send(
APIRequest(
method: .get,
path: "\(travelAlbumBase)/info",
queryItems: [URLQueryItem(name: "id", value: "\(id)")]
)
)
}
///
func editAlbum(_ request: TravelAlbumEditRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "\(travelAlbumBase)/edit", body: request)
) as EmptyPayload
}
///
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "\(travelAlbumBase)/delete", body: request)
) as EmptyPayload
}
///
func materialList(userEquityTravelId: Int, page: Int, pageSize: Int) async throws -> ListPayload<TravelAlbumMaterial> {
try await client.send(
APIRequest(
method: .get,
path: "\(travelAlbumBase)/material-list",
queryItems: [
URLQueryItem(name: "user_equity_travel_id", value: "\(userEquityTravelId)"),
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"),
URLQueryItem(name: "order_by", value: "2")
]
)
)
}
/// OSS
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
try await client.send(
APIRequest(method: .post, path: "\(travelAlbumBase)/upload-material", body: request)
)
}
///
func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "\(travelAlbumBase)/delete-material", body: request)
) as EmptyPayload
}
///
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
try await client.send(
APIRequest(
method: .get,
path: "\(travelAlbumBase)/mp-code",
queryItems: [URLQueryItem(name: "id", value: "\(id)")]
)
)
}
}

View File

@ -0,0 +1,453 @@
//
// 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 WiredTransferPhotoItem: Identifiable, Equatable {
let id: String
let sourceId: String
let fileName: String
let thumbnailURL: String
let capturedAt: String
let fileSizeText: String
let status: WiredTransferUploadStatus
let progress: Int
let errorMessage: String?
init(
id: String,
sourceId: String? = nil,
fileName: String,
thumbnailURL: String,
capturedAt: String,
fileSizeText: 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.status = status
self.progress = progress
self.errorMessage = errorMessage
}
var canSelectForSpecifyUpload: Bool {
status == .pending
}
var isNotUploaded: Bool {
status == .pending || status == .failed
}
}
/// 线
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 previewPath = [thumbnailPath, localPath, remoteURL]
.first { path in
!path.isEmpty && (path.hasPrefix("http") || FileManager.default.fileExists(atPath: path))
} ?? ""
let thumbnailURL: String
if previewPath.hasPrefix("http") {
thumbnailURL = previewPath
} else if !previewPath.isEmpty {
thumbnailURL = "file://\(previewPath)"
} else {
thumbnailURL = ""
}
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))
}
}
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
}
}

View File

@ -0,0 +1,63 @@
//
// TravelAlbumMaterialUploader.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
/// OSS + upload-material
@MainActor
final class TravelAlbumMaterialUploader: CameraAssetUploadSink {
private let api: any TravelAlbumServing
private let ossService: any OSSUploadServing
private let albumID: Int
private let scenicID: Int
/// APIOSS
init(api: any TravelAlbumServing, ossService: any OSSUploadServing, albumID: Int, scenicID: Int) {
self.api = api
self.ossService = ossService
self.albumID = albumID
self.scenicID = scenicID
}
/// OSS
func upload(
localURL: URL,
fileName: String,
fileType: Int,
progress: @escaping @Sendable (Int) -> Void
) async throws -> String {
guard albumID > 0 else {
throw APIError.serverCode(0, "请先选择有效的相册")
}
guard FileManager.default.fileExists(atPath: localURL.path) else {
throw APIError.serverCode(0, "相机文件读取失败")
}
let data = try Data(contentsOf: localURL)
guard !data.isEmpty else {
throw APIError.serverCode(0, "相机文件读取失败")
}
let remoteURL = try await ossService.uploadAlbumFile(
data: data,
fileName: fileName,
fileType: fileType,
scenicId: scenicID,
onProgress: progress
)
_ = try await api.uploadMaterial(
TravelAlbumUploadMaterialRequest(
userEquityTravelId: albumID,
fileName: fileName,
fileURL: remoteURL
)
)
return remoteURL
}
}

View File

@ -0,0 +1,13 @@
//
// TravelAlbumSession.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
///
enum TravelAlbumSession {
static var currentAlbum: TravelAlbumItem?
}

View File

@ -0,0 +1,219 @@
//
// WiredTransferPhotoStore.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
/// 线 UserDefaults
final class WiredTransferPhotoStore {
private let defaults: UserDefaults
private let accountPrefixProvider: () -> String
private let userIDProvider: () -> String
private static let keyPrefix = "wired_transfer_photos_"
private static let deletedSuffix = "_deleted"
private static let bindingsSuffix = "_photo_bindings"
/// ID
init(
defaults: UserDefaults = .standard,
accountPrefixProvider: @escaping () -> String,
userIDProvider: @escaping () -> String
) {
self.defaults = defaults
self.accountPrefixProvider = accountPrefixProvider
self.userIDProvider = userIDProvider
}
/// ID
func albumID(forPhoto photoID: String) -> Int? {
guard !photoID.isEmpty else { return nil }
return loadBindings().first { $0.photoID == photoID }?.albumID
}
///
func bindPhotoToAlbum(photoID: String, albumID: Int) {
guard !photoID.isEmpty, albumID > 0, !userIDProvider().isEmpty else { return }
var bindings = loadBindings().filter { $0.photoID != photoID }
bindings.append(PhotoAlbumBinding(photoID: photoID, albumID: albumID))
saveBindings(bindings)
}
/// ID
func loadDeletedIDs(albumID: Int) -> Set<String> {
guard albumID > 0, !userIDProvider().isEmpty else { return [] }
let ids = defaults.stringArray(forKey: deletedKey(albumID)) ?? []
return Set(ids)
}
///
func load(albumID: Int) -> [WiredTransferPhotoRecord] {
guard albumID > 0 else { return [] }
let userID = userIDProvider()
guard !userID.isEmpty else { return [] }
let deletedIDs = loadDeletedIDs(albumID: albumID)
guard let data = defaults.data(forKey: storageKey(albumID)),
let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
else { return [] }
return records
.filter { record in
!deletedIDs.contains(record.id)
&& record.userId == userID
&& record.albumId == albumID
&& record.isDisplayable
}
.map { $0.normalizeInterruptedTransfer() }
.sorted { $0.capturedAt > $1.capturedAt }
.also { items in
items.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) }
}
}
///
func save(albumID: Int, records: [WiredTransferPhotoRecord]) {
guard albumID > 0 else { return }
let userID = userIDProvider()
guard !userID.isEmpty else { return }
let normalized = records
.filter { $0.userId == userID && $0.albumId == albumID }
.sorted { $0.capturedAt > $1.capturedAt }
normalized.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) }
if let data = try? JSONEncoder().encode(normalized) {
defaults.set(data, forKey: storageKey(albumID))
}
}
///
func remove(albumID: Int, photoID: String) {
guard albumID > 0, !photoID.isEmpty else { return }
markDeleted(albumID: albumID, photoID: photoID)
let deletedIDs = loadDeletedIDs(albumID: albumID)
guard let data = defaults.data(forKey: storageKey(albumID)),
var records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
else { return }
records = records.filter { record in
!deletedIDs.contains(record.id)
&& record.userId == userIDProvider()
&& record.albumId == albumID
}
save(albumID: albumID, records: records)
}
///
func markDeleted(albumID: Int, photoID: String) {
guard albumID > 0, !photoID.isEmpty, !userIDProvider().isEmpty else { return }
let updated = loadDeletedIDs(albumID: albumID).union([photoID])
defaults.set(Array(updated), forKey: deletedKey(albumID))
}
///
func clearAlbum(albumID: Int) -> Set<String> {
guard albumID > 0 else { return [] }
let userID = userIDProvider()
guard !userID.isEmpty else { return [] }
guard let data = defaults.data(forKey: storageKey(albumID)),
let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
else { return [] }
let userRecords = records.filter { $0.userId == userID }
userRecords.forEach { deleteRecordFiles($0) }
var bindings = loadBindings().filter { $0.albumID != albumID }
saveBindings(bindings)
defaults.removeObject(forKey: storageKey(albumID))
defaults.removeObject(forKey: deletedKey(albumID))
return Set(userRecords.map(\.id))
}
private func deleteRecordFiles(_ record: WiredTransferPhotoRecord) {
deleteFileIfExists(record.localPath)
deleteFileIfExists(record.thumbnailPath)
}
private func deleteFileIfExists(_ path: String) {
guard !path.isEmpty else { return }
let url = URL(fileURLWithPath: path)
if FileManager.default.fileExists(atPath: url.path) {
try? FileManager.default.removeItem(at: url)
}
}
private func storageKey(_ albumID: Int) -> String {
"\(accountPrefixProvider())\(Self.keyPrefix)\(albumID)"
}
private func deletedKey(_ albumID: Int) -> String {
"\(storageKey(albumID))\(Self.deletedSuffix)"
}
private func bindingsKey() -> String {
"\(accountPrefixProvider())\(Self.bindingsSuffix)"
}
private struct PhotoAlbumBinding: Codable {
let photoID: String
let albumID: Int
}
private func loadBindings() -> [PhotoAlbumBinding] {
guard let data = defaults.data(forKey: bindingsKey()),
let bindings = try? JSONDecoder().decode([PhotoAlbumBinding].self, from: data)
else { return [] }
return bindings
}
private func saveBindings(_ bindings: [PhotoAlbumBinding]) {
if let data = try? JSONEncoder().encode(bindings) {
defaults.set(data, forKey: bindingsKey())
}
}
}
private extension WiredTransferPhotoRecord {
var isDisplayable: Bool {
!localPath.isEmpty || !remoteURL.isEmpty
}
func normalizeInterruptedTransfer() -> WiredTransferPhotoRecord {
guard status == WiredTransferUploadStatus.transferring.rawValue
|| status == WiredTransferUploadStatus.uploading.rawValue
else { return self }
var copy = self
copy = WiredTransferPhotoRecord(
id: id,
sourceId: sourceId,
fileName: fileName,
localPath: localPath,
thumbnailPath: thumbnailPath,
capturedAt: capturedAt,
fileSizeBytes: fileSizeBytes,
status: remoteURL.isEmpty ? WiredTransferUploadStatus.pending.rawValue : WiredTransferUploadStatus.uploaded.rawValue,
progress: remoteURL.isEmpty ? 0 : 100,
errorMessage: errorMessage,
albumId: albumId,
userId: userId,
remoteURL: remoteURL,
updatedAt: updatedAt
)
return copy
}
}
private extension Array {
@discardableResult
func also(_ body: (Self) -> Void) -> Self {
body(self)
return self
}
}

View File

@ -0,0 +1,38 @@
# TravelAlbum
## 职责
旅拍相册(新增相册 / 新建相册任务)模块,包含任务创建、相册详情、小程序码与 USB 有线传图上传。
## 入口
- 权限 URI`travel_album``TravelAlbumEntryView`
- Android 对照:`ui/travelalbum`
## 核心页面
| 页面 | 说明 |
| --- | --- |
| `TravelAlbumEntryView` | 新增相册列表、新建任务入口 |
| `CreateTravelAlbumSheet` | 先拍再买 / 买了再拍创建弹窗 |
| `TravelAlbumDetailView` | 相册详情与素材管理 |
| `WiredCameraTransferView` | 有线传图页 |
## 业务流程
1. 创建任务:`POST .../travel-album/create`
2. 进入有线传图Sony 相机 PTP 下载(`Core/CameraTethering`
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
4. 素材登记:`POST .../travel-album/upload-material`
## 解耦关系
- `CameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议
- `TravelAlbumMaterialUploader` 实现 OSS + 后端登记
- 与「相册管理」(`Features/Assets`API、Store、路由完全隔离
## 依赖模块
- `Core/CameraTethering` — USB/PTP 相机
- `Core/CameraTransfer` — 传输编排
- `Core/Upload` — OSS STS 上传

View File

@ -0,0 +1,437 @@
//
// TravelAlbumViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
import Combine
/// ViewModel
@MainActor
final class TravelAlbumEntryViewModel: ObservableObject {
static let modePreShoot = 1
static let modePreOrder = 2
static let apiTypePreShoot = 1
static let apiTypePreOrder = 2
@Published var albums: [TravelAlbumItem] = []
@Published var total = 0
@Published var isLoading = false
@Published var isCreating = false
@Published var showCreateSheet = false
@Published var bindableOrders: [TravelAlbumAvailableOrder] = []
@Published var codeSheet: TravelAlbumCodeSheetState?
@Published var errorMessage: String?
///
func loadAlbums(api: any TravelAlbumServing) async {
isLoading = true
defer { isLoading = false }
do {
let payload = try await api.albumList(page: 1, pageSize: 20)
albums = payload.list
total = payload.total
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
///
func openCreateSheet(api: any TravelAlbumServing, scenicSelected: Bool) async {
guard !isCreating else { return }
guard scenicSelected else {
errorMessage = "请先选择景区"
return
}
showCreateSheet = true
await loadBindableOrders(api: api)
}
///
func loadBindableOrders(api: any TravelAlbumServing) async {
do {
bindableOrders = try await api.availableOrders()
} catch {
errorMessage = error.localizedDescription
}
}
///
func confirmCreateTask(
api: any TravelAlbumServing,
mode: Int,
freeCount: String,
singlePrice: String,
packagePrice: String,
order: TravelAlbumAvailableOrder?,
scenicSelected: Bool
) async -> Bool {
guard !isCreating else { return false }
guard scenicSelected else {
errorMessage = "请先选择景区"
return false
}
let apiType = mode == Self.modePreOrder ? Self.apiTypePreOrder : Self.apiTypePreShoot
let albumName: String
if mode == Self.modePreOrder {
albumName = order?.projectName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? order!.projectName
: Self.buildTodayAlbumName(existingNames: albums.map(\.name))
} else {
albumName = Self.buildTodayAlbumName(existingNames: albums.map(\.name))
}
if mode == Self.modePreOrder, order == nil {
errorMessage = "请选择绑定订单"
return false
}
if mode == Self.modePreShoot, singlePrice.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
errorMessage = "请输入单张照片价格"
return false
}
let materialPrice = Double(singlePrice.trimmingCharacters(in: .whitespacesAndNewlines))
if mode == Self.modePreShoot, materialPrice == nil {
errorMessage = "请输入有效的单张照片价格"
return false
}
let request: TravelAlbumCreateRequest
if apiType == Self.apiTypePreShoot {
request = TravelAlbumCreateRequest(
name: albumName,
type: apiType,
orderNumber: nil,
materialNum: Int(freeCount) ?? 0,
materialPrice: materialPrice,
materialPackagePrice: Double(packagePrice) ?? 0,
photoPrice: 0
)
} else {
request = TravelAlbumCreateRequest(
name: albumName,
type: apiType,
orderNumber: order?.orderNumber,
materialNum: nil,
materialPrice: nil,
materialPackagePrice: nil,
photoPrice: nil
)
}
isCreating = true
defer { isCreating = false }
do {
_ = try await api.createAlbum(request)
showCreateSheet = false
await loadAlbums(api: api)
errorMessage = nil
return true
} catch {
let message = error.localizedDescription
if message.contains("订单已关联") {
await loadBindableOrders(api: api)
}
errorMessage = message.isEmpty ? "创建失败" : message
return false
}
}
///
func openAlbumCode(api: any TravelAlbumServing, album: TravelAlbumItem) async {
codeSheet = TravelAlbumCodeSheetState(albumID: album.id, albumName: album.name, isLoading: true, qrImageURL: "")
do {
let response = try await api.mpCode(id: album.id)
codeSheet?.qrImageURL = response.mpCodeOssURL.trimmingCharacters(in: .whitespacesAndNewlines)
codeSheet?.isLoading = false
} catch {
codeSheet?.isLoading = false
errorMessage = error.localizedDescription
}
}
///
static func buildTodayAlbumName(existingNames: [String]) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let prefix = formatter.string(from: Date())
let todayNames = existingNames.filter { $0.hasPrefix(prefix) }
let nextIndex = todayNames.count + 1
return String(format: "%@-%03d", prefix, nextIndex)
}
}
///
struct TravelAlbumCodeSheetState: Identifiable {
let albumID: Int
let albumName: String
var isLoading: Bool
var qrImageURL: String
var id: Int { albumID }
}
/// ViewModel
@MainActor
final class TravelAlbumDetailViewModel: ObservableObject {
@Published var album: TravelAlbumItem?
@Published var materials: [TravelAlbumMaterial] = []
@Published var isLoading = false
@Published var isDeleting = false
@Published var errorMessage: String?
///
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
}
}
///
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 deleteAlbum(api: any TravelAlbumServing, albumID: Int, photoStore: WiredTransferPhotoStore) async -> Bool {
isDeleting = true
defer { isDeleting = false }
do {
try await api.deleteAlbum(TravelAlbumDeleteRequest(id: albumID))
_ = photoStore.clearAlbum(albumID: albumID)
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
}
/// 线 ViewModel
@MainActor
final class WiredCameraTransferViewModel: ObservableObject {
static let modeLiveCapture = "边拍边传"
static let modePostShootTransfer = "拍完再传"
@Published var photos: [WiredTransferPhotoItem] = []
@Published var selectedTabIndex = 0
@Published var sidebarExpanded = true
@Published var selectedTimeSlotID: String?
@Published var selectUploadMode = false
@Published var selectedPhotoIDs: Set<String> = []
@Published var transferModeOption = modeLiveCapture
@Published var showSpecifyUploadSheet = false
@Published var connectionState: CameraConnectionState = .disconnected
@Published var errorMessage: String?
let context: WiredTransferContext
private let pipeline: CameraTransferPipeline
private var photoStore: WiredTransferPhotoStore?
private var userIDProvider: (() -> String)?
private var sessionNewPhotoIDs: Set<String> = []
/// 线 ViewModel
init(
context: WiredTransferContext,
cameraService: any CameraServiceProtocol
) {
self.context = context
self.pipeline = CameraTransferPipeline(cameraService: cameraService)
pipeline.onConnectionStateChange = { [weak self] state in
Task { @MainActor in
self?.connectionState = state
}
}
pipeline.onTasksUpdated = { [weak self] tasks in
Task { @MainActor in
self?.applyPipelineTasks(tasks)
}
}
}
/// Sink
func start(
api: any TravelAlbumServing,
ossService: any OSSUploadServing,
scenicID: Int,
accountContext: AccountContext
) async {
if photoStore == nil {
photoStore = WiredTransferPhotoStore(
accountPrefixProvider: { accountContext.accountCachePrefix ?? "guest_" },
userIDProvider: { accountContext.profile?.userId ?? "" }
)
userIDProvider = { accountContext.profile?.userId ?? "" }
}
let uploader = TravelAlbumMaterialUploader(
api: api,
ossService: ossService,
albumID: context.albumId,
scenicID: scenicID
)
let autoUpload = transferModeOption == Self.modeLiveCapture
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload)
loadPersistedPhotos()
await pipeline.connect()
}
///
func stop() async {
await pipeline.disconnect()
}
///
func selectTransferMode(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) {
guard option == Self.modeLiveCapture || option == Self.modePostShootTransfer else { return }
transferModeOption = option
let autoUpload = option == Self.modeLiveCapture
let uploader = TravelAlbumMaterialUploader(
api: api,
ossService: ossService,
albumID: context.albumId,
scenicID: scenicID
)
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload)
}
///
func batchUploadAll() async {
let pendingIDs = photos.filter { $0.isNotUploaded }.map(\.id)
await pipeline.uploadAssets(withIDs: pendingIDs)
}
///
func uploadSelected() async {
await pipeline.uploadAssets(withIDs: Array(selectedPhotoIDs))
selectUploadMode = false
selectedPhotoIDs.removeAll()
}
///
func retryPhoto(id: String) async {
await pipeline.uploadAssets(withIDs: [id])
}
///
func deletePhoto(id: String) {
photoStore?.remove(albumID: context.albumId, photoID: id)
photos.removeAll { $0.id == id }
}
///
func togglePhotoSelection(id: String) {
if selectedPhotoIDs.contains(id) {
selectedPhotoIDs.remove(id)
} else {
selectedPhotoIDs.insert(id)
}
}
///
func syncExistingPhotos() async {
await pipeline.syncExistingPhotos()
}
///
func retryFailedUploads() async {
await pipeline.retryFailedUploads()
}
private func loadPersistedPhotos() {
guard context.albumId > 0, let photoStore else { return }
let records = photoStore.load(albumID: context.albumId)
photos = records.map { $0.toPhotoItem() }
}
private func applyPipelineTasks(_ tasks: [CameraTransferTask]) {
guard let photoStore, let userIDProvider else { return }
let userID = userIDProvider()
guard !userID.isEmpty else { return }
var merged = photos.filter { photo in
!tasks.contains { $0.assetID == photo.id || $0.filename == photo.fileName }
}
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 localPath = task.localPath ?? ""
let fileSizeBytes = (try? FileManager.default.attributesOfItem(atPath: localPath)[.size] as? Int64) ?? 0
let thumbnailURL = localPath.isEmpty ? "" : "file://\(localPath)"
let item = WiredTransferPhotoItem(
id: task.assetID,
fileName: task.filename,
thumbnailURL: thumbnailURL,
capturedAt: capturedAt,
fileSizeText: WiredTransferPhotoRecord.formatFileSize(fileSizeBytes),
status: status,
progress: task.progress,
errorMessage: task.errorMessage
)
merged.insert(item, at: 0)
sessionNewPhotoIDs.insert(task.assetID)
let record = item.toRecord(
albumId: context.albumId,
userId: userID,
localPath: localPath,
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)
}
photos = merged.sorted { $0.capturedAt > $1.capturedAt }
}
private func belongsToCurrentAlbum(_ photoID: String) -> Bool {
if let bound = photoStore?.albumID(forPhoto: photoID) {
return bound == context.albumId
}
return sessionNewPhotoIDs.contains(photoID)
}
private static let captureTimestampFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
}

View File

@ -0,0 +1,158 @@
//
// CreateTravelAlbumSheet.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
///
struct CreateTravelAlbumSheet: View {
let orders: [TravelAlbumAvailableOrder]
let isCreating: Bool
let onConfirm: (Int, String, String, String, TravelAlbumAvailableOrder?) -> Void
@Environment(\.dismiss) private var dismiss
@State private var mode = TravelAlbumEntryViewModel.modePreShoot
@State private var freeCount = ""
@State private var singlePrice = ""
@State private var packagePrice = ""
@State private var selectedOrder: TravelAlbumAvailableOrder?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
modeCard(
title: "先拍再买",
description: "拍完后分享给用户,用户在小程序上选择性购买",
selected: mode == TravelAlbumEntryViewModel.modePreShoot
) {
mode = TravelAlbumEntryViewModel.modePreShoot
}
modeCard(
title: "买了再拍",
description: "用户已在小程序下单,绑定订单后用户可直接选片",
selected: mode == TravelAlbumEntryViewModel.modePreOrder
) {
mode = TravelAlbumEntryViewModel.modePreOrder
}
if mode == TravelAlbumEntryViewModel.modePreShoot {
preShootFields
} else {
preOrderFields
}
}
.padding(AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("新建相册任务")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(isCreating ? "创建中" : "确认创建") {
onConfirm(mode, freeCount, singlePrice, packagePrice, selectedOrder)
}
.disabled(isCreating)
}
}
}
}
private var preShootFields: some View {
VStack(spacing: AppMetrics.Spacing.small) {
fieldRow(title: "免费张数", placeholder: "请输入免费张数", text: $freeCount, keyboard: .numberPad)
fieldRow(title: "单张价格(元)", placeholder: "请输入单张照片价格", text: $singlePrice, keyboard: .decimalPad)
fieldRow(title: "打包价(元)", placeholder: "可选", text: $packagePrice, keyboard: .decimalPad)
}
}
private var preOrderFields: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("选择绑定订单")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
if orders.isEmpty {
Text("暂无可绑定订单")
.foregroundStyle(AppDesign.placeholder)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
} else {
ForEach(orders) { order in
Button {
selectedOrder = order
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(order.projectName)
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
Text("\(order.orderNumber) · \(order.userPhone)")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
Image(systemName: selectedOrder?.orderNumber == order.orderNumber ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selectedOrder?.orderNumber == order.orderNumber ? AppDesign.primary : AppDesign.placeholder)
}
.padding(AppMetrics.Spacing.medium)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
.buttonStyle(.plain)
}
}
}
}
private func modeCard(title: String, description: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) {
Image(systemName: selected ? "largecircle.fill.circle" : "circle")
.foregroundStyle(selected ? AppDesign.primary : AppDesign.placeholder)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text(description)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
.multilineTextAlignment(.leading)
}
Spacer()
}
.padding(AppMetrics.Spacing.large)
.background(Color.white)
.overlay(
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)
.stroke(selected ? AppDesign.primary : Color.clear, lineWidth: 1.5)
)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
.buttonStyle(.plain)
}
private func fieldRow(title: String, placeholder: String, text: Binding<String>, keyboard: UIKeyboardType) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
TextField(placeholder, text: text)
.keyboardType(keyboard)
.padding(AppMetrics.Spacing.medium)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
}
}

View File

@ -0,0 +1,46 @@
//
// TravelAlbumCodeSheet.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
///
struct TravelAlbumCodeSheet: View {
let state: TravelAlbumCodeSheetState
var body: some View {
NavigationStack {
VStack(spacing: AppMetrics.Spacing.large) {
Text(state.albumName)
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
if state.isLoading {
ProgressView("加载小程序码…")
.frame(height: 220)
} else if state.qrImageURL.isEmpty {
AppContentUnavailableView("暂无小程序码", systemImage: "qrcode")
.frame(height: 220)
} else {
RemoteImage(urlString: state.qrImageURL) {
Image(systemName: "qrcode")
.font(.system(size: 48))
.foregroundStyle(AppDesign.placeholder)
}
.frame(width: 220, height: 220)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
Text("用户可扫码进入小程序选片")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
}
.padding(AppMetrics.Spacing.pageHorizontal)
.navigationTitle("相册码")
.navigationBarTitleDisplayMode(.inline)
}
.presentationDetents([.medium])
}
}

View File

@ -0,0 +1,156 @@
//
// TravelAlbumDetailView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
///
struct TravelAlbumDetailView: View {
let albumID: Int
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.travelAlbumAPI) private var travelAlbumAPI
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = TravelAlbumDetailViewModel()
@State private var showDeleteConfirm = false
private var photoStore: WiredTransferPhotoStore {
WiredTransferPhotoStore(
accountPrefixProvider: { accountContext.accountCachePrefix ?? "guest_" },
userIDProvider: { accountContext.profile?.userId ?? "" }
)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
if let album = viewModel.album {
albumHeader(album)
}
if viewModel.materials.isEmpty, !viewModel.isLoading {
AppContentUnavailableView("暂无素材", systemImage: "photo.on.rectangle")
.frame(maxWidth: .infinity, minHeight: 200)
} else {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ForEach(viewModel.materials) { material in
materialCell(material)
}
}
}
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle(viewModel.album?.name ?? "相册详情")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
if let album = viewModel.album {
NavigationLink {
WiredCameraTransferView(context: WiredTransferContext(
albumId: album.id,
albumName: album.name,
phone: album.displayPhone,
orderNumber: album.orderNumber
))
} label: {
Image(systemName: "cable.connector")
}
.accessibilityLabel("有线传图")
}
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Image(systemName: "trash")
}
.accessibilityLabel("删除相册")
}
}
.confirmationDialog("确认删除该相册?", isPresented: $showDeleteConfirm, titleVisibility: .visible) {
Button("删除", role: .destructive) {
Task { await deleteAlbum() }
}
Button("取消", role: .cancel) {}
}
.task(id: albumID) {
globalLoading.show()
defer { globalLoading.hide() }
await viewModel.load(api: travelAlbumAPI, albumID: albumID)
}
.refreshable {
await viewModel.load(api: travelAlbumAPI, albumID: albumID)
}
.onChange(of: viewModel.errorMessage) { message in
guard let message, !message.isEmpty else { return }
toastCenter.show(message)
}
}
private func albumHeader(_ album: TravelAlbumItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(album.type == 1 ? "先拍再买" : "买了再拍")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.primary)
if !album.displayPhone.isEmpty {
Label(album.displayPhone, systemImage: "phone.fill")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
}
if !album.orderNumber.isEmpty {
Text("订单号:\(album.orderNumber)")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.large)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
private func materialCell(_ material: TravelAlbumMaterial) -> some View {
ZStack(alignment: .topTrailing) {
RemoteImage(urlString: material.coverURL.isEmpty ? material.fileURL : material.coverURL) {
Color(hex: 0xE5E7EB)
}
.aspectRatio(1, contentMode: .fill)
.clipShape(RoundedRectangle(cornerRadius: 8))
Menu {
Button(role: .destructive) {
Task {
await viewModel.deleteMaterial(api: travelAlbumAPI, materialID: material.id, albumID: albumID)
}
} label: {
Label("删除", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis")
.padding(6)
.background(Color.black.opacity(0.35))
.foregroundStyle(.white)
.clipShape(Circle())
.padding(4)
}
}
}
private func deleteAlbum() async {
globalLoading.show()
defer { globalLoading.hide() }
let success = await viewModel.deleteAlbum(api: travelAlbumAPI, albumID: albumID, photoStore: photoStore)
if success {
toastCenter.show("相册已删除")
dismiss()
}
}
}

View File

@ -0,0 +1,207 @@
//
// TravelAlbumEntryView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
///
struct TravelAlbumEntryView: View {
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.travelAlbumAPI) private var travelAlbumAPI
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@StateObject private var viewModel = TravelAlbumEntryViewModel()
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
createHeroCard
if viewModel.isLoading, viewModel.albums.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, minHeight: 200)
} else if viewModel.albums.isEmpty {
AppContentUnavailableView("暂无相册任务", systemImage: "photo.on.rectangle.angled", description: Text("点击“新建相册任务”开始创建任务"))
.frame(maxWidth: .infinity, minHeight: 240)
} else {
LazyVStack(spacing: AppMetrics.Spacing.medium) {
ForEach(viewModel.albums) { album in
TravelAlbumCard(
album: album,
onOpenDetail: { TravelAlbumSession.currentAlbum = album },
onOpenWiredTransfer: { TravelAlbumSession.currentAlbum = album },
onOpenCode: {
Task {
await viewModel.openAlbumCode(api: travelAlbumAPI, album: album)
}
}
)
}
}
}
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("新增相册")
.navigationBarTitleDisplayMode(.inline)
.refreshable {
await reload(showLoading: false)
}
.task {
await reload(showLoading: viewModel.albums.isEmpty)
}
.sheet(isPresented: $viewModel.showCreateSheet) {
CreateTravelAlbumSheet(
orders: viewModel.bindableOrders,
isCreating: viewModel.isCreating,
onConfirm: { mode, freeCount, singlePrice, packagePrice, order in
Task {
let success = await viewModel.confirmCreateTask(
api: travelAlbumAPI,
mode: mode,
freeCount: freeCount,
singlePrice: singlePrice,
packagePrice: packagePrice,
order: order,
scenicSelected: accountContext.currentScenic != nil
)
if success {
toastCenter.show("任务创建成功")
} else if let message = viewModel.errorMessage {
toastCenter.show(message)
}
}
}
)
.presentationDetents([.large])
}
.sheet(item: $viewModel.codeSheet) { state in
TravelAlbumCodeSheet(state: state)
}
.onChange(of: viewModel.errorMessage) { message in
guard let message, !message.isEmpty, !viewModel.showCreateSheet else { return }
toastCenter.show(message)
}
}
private var createHeroCard: some View {
Button {
Task {
await viewModel.openCreateSheet(
api: travelAlbumAPI,
scenicSelected: accountContext.currentScenic != nil
)
}
} label: {
HStack(spacing: AppMetrics.Spacing.medium) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 28))
.foregroundStyle(AppDesign.primary)
VStack(alignment: .leading, spacing: 4) {
Text(viewModel.isCreating ? "新建中..." : "新建相册任务")
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text("创建旅拍相册并开始有线传图")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(AppDesign.placeholder)
}
.padding(AppMetrics.Spacing.large)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
.buttonStyle(.plain)
.disabled(viewModel.isCreating)
}
private func reload(showLoading: Bool) async {
if showLoading { globalLoading.show() }
defer { if showLoading { globalLoading.hide() } }
await viewModel.loadAlbums(api: travelAlbumAPI)
}
}
private struct TravelAlbumCard: View {
let album: TravelAlbumItem
let onOpenDetail: () -> Void
let onOpenWiredTransfer: () -> Void
let onOpenCode: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) {
RemoteImage(urlString: album.coverURL) {
Color(hex: 0xE5E7EB)
.overlay { Image(systemName: "photo").foregroundStyle(AppDesign.placeholder) }
}
.frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 6) {
Text(album.name)
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
if !album.displayPhone.isEmpty {
Label(album.displayPhone, systemImage: "phone.fill")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Text(album.type == 1 ? "先拍再买" : "买了再拍")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.primary)
}
Spacer()
}
HStack(spacing: AppMetrics.Spacing.small) {
NavigationLink {
WiredCameraTransferView(context: WiredTransferContext(
albumId: album.id,
albumName: album.name,
phone: album.displayPhone,
orderNumber: album.orderNumber
))
.onAppear { onOpenWiredTransfer() }
} label: {
actionChip(title: "拍传", systemImage: "cable.connector")
}
Button(action: onOpenCode) {
actionChip(title: "相册码", systemImage: "qrcode")
}
NavigationLink {
TravelAlbumDetailView(albumID: album.id)
.onAppear { onOpenDetail() }
} label: {
actionChip(title: "详情", systemImage: "info.circle")
}
}
}
.padding(AppMetrics.Spacing.large)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
private func actionChip(title: String, systemImage: String) -> some View {
HStack(spacing: 4) {
Image(systemName: systemImage)
Text(title)
}
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(AppDesign.primary.opacity(0.08))
.clipShape(Capsule())
}
}

View File

@ -0,0 +1,301 @@
//
// WiredCameraTransferView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
import UIKit
/// 线
struct WiredCameraTransferView: View {
let context: WiredTransferContext
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.travelAlbumAPI) private var travelAlbumAPI
@Environment(\.ossUploadService) private var ossUploadService
@EnvironmentObject private var toastCenter: ToastCenter
@StateObject private var viewModel: WiredCameraTransferViewModel
init(context: WiredTransferContext) {
self.context = context
_viewModel = StateObject(
wrappedValue: WiredCameraTransferViewModel(
context: context,
cameraService: CameraServiceFactory.make(brand: .sony)
)
)
}
private let tabTitles = ["全部", "已上传", "失败"]
var body: some View {
VStack(spacing: 0) {
headerSection
tabSection
photoListSection
bottomBar
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("有线传图")
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.start(
api: travelAlbumAPI,
ossService: ossUploadService,
scenicID: accountContext.currentScenic?.id ?? 0,
accountContext: accountContext
)
}
.onDisappear {
Task { await viewModel.stop() }
}
.onChange(of: viewModel.errorMessage) { message in
guard let message, !message.isEmpty else { return }
toastCenter.show(message)
}
.onChange(of: viewModel.connectionState) { state in
switch state {
case .connected(let name):
toastCenter.show("已连接 \(name)")
case .disconnected:
break
case .error(let message):
toastCenter.show(message)
default:
break
}
}
.confirmationDialog("指定上传", isPresented: $viewModel.showSpecifyUploadSheet, titleVisibility: .visible) {
Button("上传全部待上传") {
Task { await viewModel.batchUploadAll() }
}
Button("上传选中项") {
Task { await viewModel.uploadSelected() }
}
Button("取消", role: .cancel) {}
}
}
private var headerSection: some View {
VStack(spacing: AppMetrics.Spacing.small) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(context.albumName)
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
if !context.phone.isEmpty {
Text(context.phone)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
}
Spacer()
connectionBadge
}
Picker("传输模式", selection: Binding(
get: { viewModel.transferModeOption },
set: { newValue in
viewModel.selectTransferMode(
newValue,
api: travelAlbumAPI,
ossService: ossUploadService,
scenicID: accountContext.currentScenic?.id ?? 0
)
}
)) {
Text(WiredCameraTransferViewModel.modeLiveCapture).tag(WiredCameraTransferViewModel.modeLiveCapture)
Text(WiredCameraTransferViewModel.modePostShootTransfer).tag(WiredCameraTransferViewModel.modePostShootTransfer)
}
.pickerStyle(.segmented)
}
.padding(AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
.background(Color.white)
}
private var connectionBadge: some View {
VStack(spacing: 4) {
Circle()
.stroke(connectionColor, lineWidth: 3)
.frame(width: 44, height: 44)
.overlay {
Image(systemName: "camera.fill")
.foregroundStyle(connectionColor)
}
Text(connectionText)
.font(.system(size: 10))
.foregroundStyle(AppDesign.textSecondary)
.multilineTextAlignment(.center)
.frame(width: 72)
}
}
private var connectionColor: Color {
switch viewModel.connectionState {
case .connected: AppDesign.success
case .error: Color.red
case .searching, .connecting: AppDesign.warning
case .disconnected: AppDesign.placeholder
}
}
private var connectionText: String {
switch viewModel.connectionState {
case .connected(let name):
name
default:
viewModel.connectionState.displayText
}
}
private var tabSection: some View {
let counts = viewModel.photos.transferTabCounts
return HStack(spacing: 0) {
ForEach(Array(tabTitles.enumerated()), id: \.offset) { index, title in
Button {
viewModel.selectedTabIndex = index
} label: {
VStack(spacing: 4) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: viewModel.selectedTabIndex == index ? .semibold : .regular))
Text("\(counts[index])")
.font(.system(size: AppMetrics.FontSize.caption))
}
.foregroundStyle(viewModel.selectedTabIndex == index ? AppDesign.primary : AppDesign.textSecondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(viewModel.selectedTabIndex == index ? AppDesign.primarySoft : Color.clear)
}
.buttonStyle(.plain)
}
}
.background(Color.white)
}
private var photoListSection: some View {
let visiblePhotos = viewModel.photos.filtered(byTabIndex: viewModel.selectedTabIndex)
return ScrollView {
if visiblePhotos.isEmpty {
AppContentUnavailableView("暂无照片", systemImage: "photo")
.frame(maxWidth: .infinity, minHeight: 260)
} else {
LazyVStack(spacing: AppMetrics.Spacing.small) {
ForEach(visiblePhotos) { photo in
photoRow(photo)
}
}
.padding(AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
}
}
private func photoRow(_ photo: WiredTransferPhotoItem) -> some View {
HStack(spacing: AppMetrics.Spacing.medium) {
if viewModel.selectUploadMode, photo.canSelectForSpecifyUpload {
Button {
viewModel.togglePhotoSelection(id: photo.id)
} label: {
Image(systemName: viewModel.selectedPhotoIDs.contains(photo.id) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(viewModel.selectedPhotoIDs.contains(photo.id) ? AppDesign.primary : AppDesign.placeholder)
}
.buttonStyle(.plain)
}
thumbnailView(for: photo)
.frame(width: 64, height: 64)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
Text(photo.fileName)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.lineLimit(1)
Text(photo.capturedAt)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
HStack {
Text(photo.status.displayLabel)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(statusColor(photo.status))
if photo.status == .uploading || photo.status == .transferring {
Text("\(photo.progress)%")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
}
}
Spacer()
if photo.status == .failed {
Button("重试") {
Task { await viewModel.retryPhoto(id: photo.id) }
}
.font(.system(size: AppMetrics.FontSize.caption))
}
}
.padding(AppMetrics.Spacing.medium)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
@ViewBuilder
private func thumbnailView(for photo: WiredTransferPhotoItem) -> some View {
if photo.thumbnailURL.hasPrefix("file://"), let url = URL(string: photo.thumbnailURL) {
if let uiImage = UIImage(contentsOfFile: url.path) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
placeholderThumbnail
}
} else {
RemoteImage(urlString: photo.thumbnailURL) {
placeholderThumbnail
}
}
}
private var placeholderThumbnail: some View {
Color(hex: 0xE5E7EB)
.overlay {
Image(systemName: "photo")
.foregroundStyle(AppDesign.placeholder)
}
}
private func statusColor(_ status: WiredTransferUploadStatus) -> Color {
switch status {
case .uploaded: AppDesign.success
case .failed: Color.red
case .uploading, .transferring: AppDesign.warning
case .pending: AppDesign.textSecondary
}
}
private var bottomBar: some View {
HStack(spacing: AppMetrics.Spacing.small) {
Button("同步历史") {
Task { await viewModel.syncExistingPhotos() }
}
.buttonStyle(.bordered)
Button("批量上传") {
Task { await viewModel.batchUploadAll() }
}
.buttonStyle(.borderedProminent)
Button("指定上传") {
viewModel.selectUploadMode = true
viewModel.showSpecifyUploadSheet = true
}
.buttonStyle(.bordered)
}
.padding(AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
.background(Color.white)
}
}