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:
130
suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift
Normal file
130
suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift
Normal 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)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
453
suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
Normal file
453
suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
/// 初始化上传器,注入 API、OSS 与业务上下文。
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
//
|
||||
// TravelAlbumSession.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 旅拍相册跨页会话上下文,保存当前操作的相册任务。
|
||||
enum TravelAlbumSession {
|
||||
static var currentAlbum: TravelAlbumItem?
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
38
suixinkan/Features/TravelAlbum/TravelAlbum.md
Normal file
38
suixinkan/Features/TravelAlbum/TravelAlbum.md
Normal 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 上传
|
||||
@ -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
|
||||
}()
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
156
suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift
Normal file
156
suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
207
suixinkan/Features/TravelAlbum/Views/TravelAlbumEntryView.swift
Normal file
207
suixinkan/Features/TravelAlbum/Views/TravelAlbumEntryView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user