Files
suixinkan_ios_new/suixinkan/Features/Profile/Views/ProfileView.swift
2026-06-22 15:35:12 +08:00

649 lines
23 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 SwiftUI
/// 退
struct ProfileView: View {
@Environment(AppSession.self) private var appSession
@Environment(AccountContext.self) private var accountContext
@Environment(PermissionContext.self) private var permissionContext
@Environment(ScenicSpotContext.self) private var scenicSpotContext
@Environment(AppRouter.self) private var appRouter
@Environment(RouterPath.self) private var router
@Environment(ProfileAPI.self) private var profileAPI
@Environment(ToastCenter.self) private var toastCenter
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var viewModel = ProfileViewModel()
@State private var showsPasswordSheet = false
@State private var showsLogoutConfirm = false
@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) {}
}
.overlay {
if viewModel.isLoading && viewModel.userInfo == nil {
ProgressView()
.controlSize(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white.opacity(0.35))
}
}
.onChange(of: viewModel.isEditingProfile) { _, isEditing in
guard isEditing else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
nicknameFocused = true
}
}
}
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 {
Button {
toastCenter.show("头像上传待接入阿里云 OSS")
} label: {
AsyncAvatarImage(urlString: viewModel.displayAvatarURL)
.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)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel("头像")
}
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)
}
.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 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 viewModel.saveProfile(api: profileAPI)
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 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 AsyncAvatarImage: View {
let urlString: String
var body: some View {
if let url = URL(string: urlString), !urlString.isEmpty {
AsyncImage(url: url) { phase in
switch phase {
case let .success(image):
image
.resizable()
.scaledToFill()
case .failure, .empty:
placeholder
@unknown default:
placeholder
}
}
} else {
placeholder
}
}
private var placeholder: some View {
Image(systemName: "person.fill")
.font(.system(size: 44, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppDesign.primarySoft)
}
}
///
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()
.environment(AppSession())
.environment(AccountContext())
.environment(PermissionContext())
.environment(ScenicSpotContext())
.environment(AppRouter())
.environment(ToastCenter())
.environment(ProfileAPI(client: APIClient()))
.environment(AuthSessionCoordinator())
}
}