// // 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(@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, 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, end: Binding) -> 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) -> 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 } }