切换账号时以用户选中账号为准写入展示标题与业务作用域,避免沿用旧门店名称;同时将首页举报摄影师入口 URI 对齐为 report_photographer。 Co-authored-by: Cursor <cursoragent@cursor.com>
735 lines
29 KiB
Swift
735 lines
29 KiB
Swift
//
|
||
// 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,
|
||
currentAccountDisplayTitle: accountContext.currentAccountDisplayTitle ?? existing?.currentAccountDisplayTitle,
|
||
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
|
||
}
|
||
}
|