Initial commit

This commit is contained in:
2026-06-22 11:28:01 +08:00
commit ace9c94359
84 changed files with 8899 additions and 0 deletions

View File

@ -0,0 +1,636 @@
//
// 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(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 {
toastCenter.show("账号切换待接入")
} 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 {
toastCenter.show("实名认证页面待接入")
} label: {
infoRow(title: "认证状态") {
realNameStatusView
rowChevron
}
}
.buttonStyle(.plain)
divider
infoRow(title: "账号状态") {
accountStatusView
}
divider
infoRow(title: "当前景区") {
scenicStatusView
}
}
.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())
}
}