// // ProfileView.swift // suixinkan // // Created by Codex on 2026/6/20. // import PhotosUI import SwiftUI import UIKit /// 个人信息页视图,展示用户资料、账号状态、实名认证状态和退出登录入口。 struct ProfileView: View { @EnvironmentObject private var appSession: AppSession @EnvironmentObject private var accountContext: AccountContext @EnvironmentObject private var permissionContext: PermissionContext @EnvironmentObject private var scenicSpotContext: ScenicSpotContext @EnvironmentObject private var appRouter: AppRouter @EnvironmentObject private var router: RouterPath @Environment(\.profileAPI) private var profileAPI @Environment(\.ossUploadService) private var ossUploadService @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.authSessionCoordinator) private var authSessionCoordinator @Environment(\.globalLoading) private var globalLoading @Environment(\.horizontalSizeClass) private var horizontalSizeClass @StateObject private var viewModel = ProfileViewModel() @State private var showsPasswordSheet = false @State private var showsLogoutConfirm = false @State private var pickedAvatarItem: PhotosPickerItem? @FocusState private var nicknameFocused: Bool private var contentMaxWidth: CGFloat { horizontalSizeClass == .regular ? 720 : .infinity } var body: some View { ScrollView { VStack(spacing: 22) { header infoCard logoutButton } .padding(.horizontal, 19) .padding(.top, 24) .padding(.bottom, 24) .frame(maxWidth: contentMaxWidth) .frame(maxWidth: .infinity) } .background(profileBackground.ignoresSafeArea()) .navigationTitle("个人信息") .navigationBarTitleDisplayMode(.inline) .task { viewModel.applyCachedProfile(accountContext.profile) await reloadProfile() } .refreshable { await reloadProfile() } .sheet(isPresented: $showsPasswordSheet) { PasswordUpdateSheet { password in await updatePassword(password) } } .onChange(of: viewModel.isEditingProfile) { isEditing in guard isEditing else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { nicknameFocused = true } } .onChange(of: pickedAvatarItem) { item in Task { await prepareAvatarImage(from: item) } } } private var profileBackground: some View { LinearGradient( colors: [ Color(hex: 0xF7FAFF), Color(hex: 0xF3F7FD), Color.white ], startPoint: .top, endPoint: .bottom ) } private var header: some View { HStack(alignment: .center, spacing: 14) { avatarImage VStack(alignment: .leading, spacing: 8) { if viewModel.isEditingProfile { TextField("请输入昵称", text: nicknameBinding) .font(.system(size: 24, weight: .semibold)) .foregroundStyle(Color(hex: 0x252525)) .tint(AppDesign.primary) .textFieldStyle(.plain) .focused($nicknameFocused) .submitLabel(.done) .padding(.horizontal, 10) .frame(height: 42) .background(.white, in: RoundedRectangle(cornerRadius: 8)) .overlay { RoundedRectangle(cornerRadius: 8) .stroke(AppDesign.primary, lineWidth: 1) } .onSubmit { Task { await saveProfileEdits() } } } else { Text(viewModel.displayNickname) .font(.system(size: 24, weight: .semibold)) .foregroundStyle(Color(hex: 0x252525)) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) } Text("UID: \(accountContext.profile?.userId.isEmpty == false ? accountContext.profile?.userId ?? "--" : "--")") .font(.system(size: 18, weight: .semibold)) .foregroundStyle(Color(hex: 0x606A7A)) .lineLimit(1) } .layoutPriority(1) Spacer(minLength: 0) Button { if viewModel.isEditingProfile { Task { await saveProfileEdits() } } else { viewModel.beginEditing() } } label: { ZStack { if viewModel.isSaving && viewModel.isEditingProfile { ProgressView() .tint(.white) .scaleEffect(0.82) } else { Image(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil") .font(.system(size: 18, weight: .semibold)) } } .foregroundStyle(.white) .frame(width: 44, height: 44) .background( LinearGradient( colors: [Color(hex: 0x238BFF), Color(hex: 0x006BFF)], startPoint: .topLeading, endPoint: .bottomTrailing ), in: Circle() ) .shadow(color: Color(hex: 0x0073FF, alpha: 0.24), radius: 10, x: 0, y: 5) } .buttonStyle(.plain) .disabled(viewModel.isSaving) .opacity(viewModel.isSaving ? 0.6 : 1) .accessibilityLabel(viewModel.isEditingProfile ? "完成编辑" : "编辑个人信息") } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 18) .padding(.vertical, 21) .frame(minHeight: 132) .background { RoundedRectangle(cornerRadius: 17, style: .continuous) .fill(.white) ProfileHeaderWaveShape() .fill( LinearGradient( colors: [ Color(hex: 0xBFDFFF, alpha: 0.18), Color(hex: 0x8EC4FF, alpha: 0.28) ], startPoint: .topLeading, endPoint: .bottomTrailing ) ) ProfileHeaderWaveShape(phase: 0.35) .fill(Color(hex: 0xE4F1FF, alpha: 0.62)) } .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 17, style: .continuous) .stroke(Color(hex: 0xE4ECF7), lineWidth: 1) } .shadow(color: Color(hex: 0xC8D7EA, alpha: 0.32), radius: 16, x: 0, y: 8) } private var avatarImage: some View { PhotosPicker(selection: $pickedAvatarItem, matching: .images) { avatarPreview .frame(width: 86, height: 86) .clipShape(Circle()) .overlay { Circle() .stroke(.white, lineWidth: 3) } .overlay(alignment: .bottomTrailing) { Image(systemName: "camera.fill") .font(.system(size: 12, weight: .bold)) .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: 14, weight: .semibold)) .foregroundStyle(.white) } } } } .buttonStyle(.plain) .disabled(viewModel.isSaving) .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.displayAvatarURL) } } private var infoCard: some View { VStack(spacing: 0) { infoRow(title: "姓名") { plainInfoValue(viewModel.displayRealName) } divider Button { router.navigate(to: .profile(.accountSwitch)) } label: { infoRow(title: "当前账号") { VStack(alignment: .trailing, spacing: 3) { rowValueText(currentAccountDisplayName) Text(currentAccountTypeText) .font(.system(size: 12, weight: .medium)) .foregroundStyle(AppDesign.textSecondary) .lineLimit(1) } rowChevron } } .buttonStyle(.plain) divider infoRow(title: "手机号") { plainInfoValue(viewModel.displayPhone) } divider Button { showsPasswordSheet = true } label: { infoRow(title: "修改密码") { rowChevron } } .buttonStyle(.plain) divider Button { router.navigate(to: .profile(.realNameAuth)) } label: { infoRow(title: "认证状态") { realNameStatusView rowChevron } } .buttonStyle(.plain) divider infoRow(title: "账号状态") { accountStatusView } divider infoRow(title: "当前景区") { scenicStatusView } divider Button { router.navigate(to: .profile(.settings)) } label: { infoRow(title: "系统设置") { rowChevron } } .buttonStyle(.plain) #if DEBUG divider Button { router.navigate(to: .profile(.debugHomeMenus)) } label: { infoRow(title: "首页调试") { statusBadge(text: "DEBUG", icon: "hammer.fill", foreground: AppDesign.primary, background: AppDesign.primarySoft) rowChevron } } .buttonStyle(.plain) #endif } .padding(.horizontal, 24) .padding(.vertical, 4) .background(.white, in: RoundedRectangle(cornerRadius: 17, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 17, style: .continuous) .stroke(Color(hex: 0xEEF3FA), lineWidth: 1) } .shadow(color: Color(hex: 0xC8D7EA, alpha: 0.30), radius: 16, x: 0, y: 8) } private var divider: some View { Rectangle() .fill(Color(hex: 0xE8EDF4)) .frame(height: 1) } private var logoutButton: some View { Button { showsLogoutConfirm = true } label: { Text("退出登录") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(Color(hex: 0xFF3B3B)) .frame(maxWidth: .infinity) .frame(height: 58) .background(.white, in: RoundedRectangle(cornerRadius: 17, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 17, style: .continuous) .stroke(Color(hex: 0xEEF3FA), lineWidth: 1) } .shadow(color: Color(hex: 0xC8D7EA, alpha: 0.24), radius: 14, x: 0, y: 7) } .buttonStyle(.plain) .confirmationDialog("确认退出当前账号?", isPresented: $showsLogoutConfirm, titleVisibility: .visible) { Button("退出登录", role: .destructive) { authSessionCoordinator.logout( appSession: appSession, accountContext: accountContext, permissionContext: permissionContext, scenicSpotContext: scenicSpotContext, appRouter: appRouter, toastCenter: toastCenter ) } Button("取消", role: .cancel) {} } } /// 构造资料信息行,统一左右布局和点击区域。 private func infoRow(title: String, @ViewBuilder content: () -> Content) -> some View { HStack(spacing: 8) { Text(title) .font(.system(size: 20, weight: .semibold)) .foregroundStyle(Color(hex: 0x101827)) .frame(width: 104, alignment: .leading) Spacer(minLength: 4) content() } .frame(maxWidth: .infinity, alignment: .leading) .frame(height: 69) .contentShape(Rectangle()) } @ViewBuilder /// 根据文本是否为空占位,选择普通文本或状态徽标展示。 private func plainInfoValue(_ text: String) -> some View { if text == "--" { statusBadge(text: text, foreground: Color(hex: 0x606A7A), background: Color(hex: 0xF0F2F6)) } else { rowValueText(text) } } /// 构造资料行右侧的普通值文本。 private func rowValueText(_ text: String) -> some View { Text(text) .font(.system(size: 18, weight: .medium)) .foregroundStyle(Color(hex: 0x333333)) .lineLimit(1) .fixedSize(horizontal: false, vertical: true) } private var rowChevron: some View { Image(systemName: "chevron.right") .font(.system(size: 22, weight: .medium)) .foregroundStyle(Color(hex: 0x9AA1AA)) } @ViewBuilder private var realNameStatusView: some View { if viewModel.realNameInfo == nil { Text(viewModel.realNameStatusText) .font(.system(size: 17, weight: .medium)) .foregroundStyle(Color(hex: 0xFF7B00)) .lineLimit(1) } else { statusBadge( text: viewModel.realNameStatusText, icon: viewModel.realNameInfo?.auditStatus == 2 ? "checkmark.shield.fill" : nil, foreground: realNameStatusColor, background: realNameStatusBackground ) } } private var accountStatusView: some View { let text = viewModel.accountStatusText return Group { if text == "--" { statusBadge(text: text, foreground: Color(hex: 0x606A7A), background: Color(hex: 0xF0F2F6)) } else { statusBadge(text: text, icon: "checkmark.circle.fill", foreground: Color(hex: 0x14964A), background: Color(hex: 0xDDF8E9)) } } } private var scenicStatusView: some View { let text = nonEmpty(accountContext.currentScenic?.name) ?? "--" return Group { if text == "--" { statusBadge(text: text, foreground: Color(hex: 0x606A7A), background: Color(hex: 0xF0F2F6)) } else { statusBadge(text: text, icon: "mappin.circle.fill", foreground: AppDesign.primary, background: Color(hex: 0xE7F1FF)) } } } /// 构造状态徽标,支持可选图标、前景色和背景色。 private func statusBadge(text: String, icon: String? = nil, foreground: Color, background: Color) -> some View { HStack(spacing: 7) { if let icon { Image(systemName: icon) .font(.system(size: 17, weight: .semibold)) } Text(text) .lineLimit(1) } .font(.system(size: 17, weight: .semibold)) .foregroundStyle(foreground) .padding(.horizontal, icon == nil ? 14 : 12) .frame(height: 34) .background(background, in: Capsule()) } private var realNameStatusColor: Color { guard let realNameInfo = viewModel.realNameInfo else { return Color(hex: 0xFF7B00) } switch realNameInfo.auditStatus { case 2: return Color(hex: 0x14964A) case 3: return Color(hex: 0xEF4444) default: return Color(hex: 0xFF7B00) } } private var realNameStatusBackground: Color { guard let realNameInfo = viewModel.realNameInfo else { return Color(hex: 0xF4F4F4) } switch realNameInfo.auditStatus { case 2: return Color(hex: 0xDDF8E9) case 3: return Color(hex: 0xFFE7E7) default: return Color(hex: 0xFFF0E2) } } private var currentAccountDisplayName: String { nonEmpty(accountContext.currentStore?.name) ?? nonEmpty(accountContext.currentScenic?.name) ?? accountContext.profile?.displayName ?? viewModel.displayNickname } private var currentAccountTypeText: String { if accountContext.currentStore != nil { return "门店账号" } if accountContext.currentScenic != nil { return "景区账号" } return nonEmpty(viewModel.userInfo?.roleName) ?? "账号" } private var nicknameBinding: Binding { Binding( get: { viewModel.editingNickname }, set: { viewModel.editingNickname = $0 } ) } /// 重新拉取个人资料,并同步更新全局账号资料。 private func reloadProfile() async { do { try await globalLoading.withOptionalLoading(viewModel.userInfo == nil, message: "加载中...") { try await viewModel.reload(api: profileAPI) } if let userInfo = viewModel.userInfo { authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext) } else { accountContext.replaceProfile(viewModel.accountProfileFallback(accountContext.profile)) } } catch is CancellationError { return } catch { toastCenter.show(error.localizedDescription) } } /// 保存昵称编辑内容,并在成功后刷新全局账号展示。 private func saveProfileEdits() async { do { try await globalLoading.withLoading(message: "保存中...") { try await viewModel.saveProfile( api: profileAPI, uploader: ossUploadService, scenicId: accountContext.currentScenic?.id ?? 0 ) } if let userInfo = viewModel.userInfo { authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext) } else { accountContext.replaceProfile(viewModel.accountProfileFallback(accountContext.profile)) } toastCenter.show("个人信息已保存") } catch { toastCenter.show(error.localizedDescription) } } /// 处理用户选择的头像图片,并生成待上传数据。 private func prepareAvatarImage(from item: PhotosPickerItem?) async { guard let item else { return } defer { pickedAvatarItem = nil } do { guard let data = try await item.loadTransferable(type: Data.self) else { toastCenter.show("请选择有效的头像图片") return } try viewModel.prepareAvatarImage(data: data) } catch { toastCenter.show(error.localizedDescription) } } /// 提交新密码,并在成功后关闭密码弹窗。 private func updatePassword(_ password: String) async { do { try await viewModel.updatePassword(password, api: profileAPI) showsPasswordSheet = false toastCenter.show("密码修改成功") } catch { toastCenter.show(error.localizedDescription) } } /// 去除空白字符后返回非空字符串。 private func nonEmpty(_ value: String?) -> String? { let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return text.isEmpty ? nil : text } } /// 个人信息头部背景波浪形状,用于还原旧版蓝白卡片视觉。 private struct ProfileHeaderWaveShape: Shape { var phase: CGFloat = 0 /// 根据当前矩形尺寸生成波浪路径。 func path(in rect: CGRect) -> Path { var path = Path() let startY = rect.height * (0.94 - phase * 0.16) path.move(to: CGPoint(x: rect.width * (0.26 + phase * 0.16), y: rect.height)) path.addCurve( to: CGPoint(x: rect.width, y: rect.height * (0.26 + phase * 0.08)), control1: CGPoint(x: rect.width * (0.55 + phase * 0.06), y: startY), control2: CGPoint(x: rect.width * (0.70 + phase * 0.08), y: rect.height * (0.40 + phase * 0.14)) ) path.addLine(to: CGPoint(x: rect.width, y: rect.height)) path.closeSubpath() return path } } /// 修改密码弹窗,负责采集新密码并提交给外部动作。 private struct PasswordUpdateSheet: View { @Environment(\.dismiss) private var dismiss @State private var password = "" let onSubmit: (String) async -> Void var body: some View { NavigationStack { VStack(alignment: .leading, spacing: 16) { SecureField("请输入新密码", text: $password) .textContentType(.newPassword) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) .padding(.horizontal, 12) .frame(height: 48) .background(Color(hex: 0xF5F7FA), in: RoundedRectangle(cornerRadius: 8)) .overlay { RoundedRectangle(cornerRadius: 8) .stroke(Color(hex: 0xE2E8F0), lineWidth: 1) } Text("密码至少 6 位。") .font(.system(size: 13)) .foregroundStyle(AppDesign.textSecondary) Spacer() } .padding(16) .navigationTitle("修改密码") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("完成") { Task { await onSubmit(password) } } .disabled(password.trimmingCharacters(in: .whitespacesAndNewlines).count < 6) } } } .presentationDetents([.height(220)]) } } #Preview { NavigationStack { ProfileView() .environmentObject(AppSession()) .environmentObject(AccountContext()) .environmentObject(PermissionContext()) .environmentObject(ScenicSpotContext()) .environmentObject(AppRouter()) .environmentObject(ToastCenter()) .environment(\.profileAPI, ProfileAPI(client: APIClient())) .environment(\.ossUploadService, OSSUploadService(configService: UploadAPI(client: APIClient()))) .environment(\.authSessionCoordinator, AuthSessionCoordinator()) } }