Files
suixinkan_ios_new/suixinkan/Features/Profile/Views/ProfileView.swift
汉秋 703078352c 从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构
将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

676 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 {
await reloadProfile(showToast: false)
}
.refreshable {
await reloadProfile(showToast: true)
}
.sheet(isPresented: $showsPasswordSheet) {
PasswordUpdateSheet { password in
await updatePassword(password)
}
}
.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) {}
}
.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)
}
///
private func infoRow<Content: View>(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<String> {
Binding(
get: { viewModel.editingNickname },
set: { viewModel.editingNickname = $0 }
)
}
///
private func reloadProfile(showToast: Bool) async {
do {
try await globalLoading.withOptionalLoading(!showToast && 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))
}
if showToast {
toastCenter.show("个人信息已刷新")
}
} 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())
}
}