Add TravelAlbum, ProfileSpace, and wired camera transfer modules.

Introduce travel album entry with Sony PTP tethering pipeline, profile space settings page, home routing updates, Launch Screen storyboard, and related tests/docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 09:41:05 +08:00
parent 5692134efc
commit d2fe5d71e4
48 changed files with 6477 additions and 11 deletions

View File

@ -0,0 +1,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())
}
}