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:
733
suixinkan/Features/ProfileSpace/Views/ProfileSpaceView.swift
Normal file
733
suixinkan/Features/ProfileSpace/Views/ProfileSpaceView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user