Files
suixinkan_ios_new/suixinkan/Features/Profile/Views/ProfileView.swift
汉秋 5ab9b1b324 修复账号切换后「我的」页当前账号展示错误,并统一举报摄影师首页 URI。
切换账号时以用户选中账号为准写入展示标题与业务作用域,避免沿用旧门店名称;同时将首页举报摄影师入口 URI 对齐为 report_photographer。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 17:21:04 +08:00

705 lines
25 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 {
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<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.currentAccountDisplayTitle)
?? (isStoreAccountContext ? resolvedStoreAccountName : resolvedScenicAccountName)
}
/// 退
private var resolvedStoreAccountName: String {
nonEmpty(accountContext.currentStore?.name)
?? nonEmpty(matchedStoreScope?.name)
?? nonEmpty(accountContext.profile?.displayName)
?? viewModel.displayNickname
}
///
private var resolvedScenicAccountName: String {
nonEmpty(accountContext.currentScenic?.name)
?? nonEmpty(accountContext.profile?.displayName)
?? viewModel.displayNickname
}
///
private var isStoreAccountContext: Bool {
accountContext.accountType == V9StoreUser.accountTypeValue
}
/// currentStore
private var matchedStoreScope: BusinessScope? {
if let currentStore = accountContext.currentStore {
return currentStore
}
guard accountContext.accountType == V9StoreUser.accountTypeValue else { return nil }
if accountContext.storeScopes.count == 1 {
return accountContext.storeScopes.first
}
return nil
}
private var currentAccountTypeText: String {
switch accountContext.accountType {
case V9StoreUser.accountTypeValue:
return "门店账号"
case V9ScenicUser.accountTypeValue:
return "景区账号"
default:
return nonEmpty(viewModel.userInfo?.roleName) ?? "账号"
}
}
private var nicknameBinding: Binding<String> {
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())
}
}