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,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
}
}