Initial commit
This commit is contained in:
66
suixinkan/Features/Profile/API/ProfileAPI.swift
Normal file
66
suixinkan/Features/Profile/API/ProfileAPI.swift
Normal file
@ -0,0 +1,66 @@
|
||||
//
|
||||
// ProfileAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 个人信息 API,封装“我的”页面需要的用户资料和资料更新接口。
|
||||
final class ProfileAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
|
||||
/// 初始化个人信息 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 拉取当前登录用户的基础资料。
|
||||
func userInfo() async throws -> UserInfoResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/userinfo"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取当前登录用户的实名认证状态。
|
||||
func realNameInfo() async throws -> RealNameInfoResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/real-name/info"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 更新用户昵称、密码或头像地址。
|
||||
func updateUserInfo(nickname: String? = nil, password: String? = nil, avatar: String? = nil) async throws {
|
||||
let request = UpdateInfoRequest(nickname: nickname, password: password, avatar: avatar)
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/userinfo-update",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 单独更新用户头像 URL,供 OSS 上传完成后回写服务端。
|
||||
func updateUserAvatarURL(_ fileURL: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/userinfo-update-avatar-url",
|
||||
queryItems: [URLQueryItem(name: "file_url", value: fileURL)]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileAPI: UserProfileServing {}
|
||||
142
suixinkan/Features/Profile/Models/ProfileModels.swift
Normal file
142
suixinkan/Features/Profile/Models/ProfileModels.swift
Normal file
@ -0,0 +1,142 @@
|
||||
//
|
||||
// ProfileModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 用户资料响应实体,表示“我的”页面展示的账号基础信息。
|
||||
struct UserInfoResponse: Decodable, Equatable {
|
||||
let avatar: String
|
||||
let realName: String
|
||||
let phone: String
|
||||
let nickname: String
|
||||
let roleName: String
|
||||
let status: Int
|
||||
let statusName: String
|
||||
|
||||
/// 用户资料响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case avatar
|
||||
case realName = "real_name"
|
||||
case phone
|
||||
case nickname
|
||||
case roleName = "role_name"
|
||||
case status
|
||||
case statusName = "status_name"
|
||||
}
|
||||
|
||||
/// 创建用户资料实体,主要用于本地更新和预览默认值。
|
||||
init(
|
||||
avatar: String = "",
|
||||
realName: String = "",
|
||||
phone: String = "",
|
||||
nickname: String = "",
|
||||
roleName: String = "",
|
||||
status: Int = 0,
|
||||
statusName: String = ""
|
||||
) {
|
||||
self.avatar = avatar
|
||||
self.realName = realName
|
||||
self.phone = phone
|
||||
self.nickname = nickname
|
||||
self.roleName = roleName
|
||||
self.status = status
|
||||
self.statusName = statusName
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容后端字段类型不稳定的情况。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
avatar = try container.decodeLossyString(forKey: .avatar)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
nickname = try container.decodeLossyString(forKey: .nickname)
|
||||
roleName = try container.decodeLossyString(forKey: .roleName)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusName = try container.decodeLossyString(forKey: .statusName)
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户资料更新请求实体,表示昵称、密码和头像修改参数。
|
||||
struct UpdateInfoRequest: Encodable {
|
||||
let nickname: String?
|
||||
let password: String?
|
||||
let avatar: String?
|
||||
}
|
||||
|
||||
/// 实名认证响应实体,包裹当前用户的认证详情。
|
||||
struct RealNameInfoResponse: Decodable, Equatable {
|
||||
let realNameInfo: RealNameInfo?
|
||||
|
||||
/// 实名认证响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realNameInfo = "real_name_info"
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证信息实体,表示当前用户实名审核状态和失败原因。
|
||||
struct RealNameInfo: Decodable, Equatable {
|
||||
let realName: String
|
||||
let auditStatus: Int
|
||||
let auditStatusText: String?
|
||||
let rejectReason: String?
|
||||
|
||||
/// 实名认证信息的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case auditStatus = "audit_status"
|
||||
case auditStatusText = "audit_status_text"
|
||||
case rejectReason = "reject_reason"
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容审核状态字段类型不稳定的情况。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0
|
||||
auditStatusText = try container.decodeIfPresent(String.self, forKey: .auditStatusText)
|
||||
rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason)
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
75
suixinkan/Features/Profile/Profile.md
Normal file
75
suixinkan/Features/Profile/Profile.md
Normal file
@ -0,0 +1,75 @@
|
||||
# Profile 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Profile 模块负责“我的/个人信息”页面,包括用户资料展示、昵称编辑、密码修改、实名认证状态展示和退出登录。
|
||||
|
||||
该模块聚焦个人资料业务:
|
||||
- 拉取用户基础资料。
|
||||
- 拉取实名认证信息。
|
||||
- 展示头像、昵称、UID、手机号、账号状态和实名认证状态。
|
||||
- 修改昵称。
|
||||
- 修改密码。
|
||||
- 退出登录入口。
|
||||
|
||||
登录态清理和缓存清理由 App 模块的 `AuthSessionCoordinator` 统一处理。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `ProfileView`:个人信息页 UI,负责展示资料、编辑入口、密码弹窗和退出确认。
|
||||
- `ProfileViewModel`:维护资料加载状态、编辑状态、保存状态和展示文案。
|
||||
- `ProfileAPI`:封装用户资料、实名认证和资料更新接口。
|
||||
- `UserInfoResponse`:用户基础资料。
|
||||
- `RealNameInfoResponse` / `RealNameInfo`:实名认证信息。
|
||||
- `UpdateInfoRequest`:昵称、密码或头像更新请求体。
|
||||
|
||||
## 加载流程
|
||||
|
||||
1. `ProfileView.task` 进入页面时调用 `reloadProfile(showToast: false)`。
|
||||
2. 下拉刷新时调用 `reloadProfile(showToast: true)`。
|
||||
3. `ProfileViewModel.reload` 并发请求:
|
||||
- `ProfileAPI.userInfo`
|
||||
- `ProfileAPI.realNameInfo`
|
||||
4. 请求成功后更新 `userInfo` 和 `realNameInfo`。
|
||||
5. `ProfileView` 将最新 `userInfo` 交给 `AuthSessionCoordinator.refreshCachedProfile`。
|
||||
6. 协调器更新 `AccountContext.profile`,并同步刷新账号快照。
|
||||
|
||||
## 昵称编辑流程
|
||||
|
||||
1. 用户点击编辑按钮。
|
||||
2. `ProfileViewModel.beginEditing` 进入编辑状态,并把当前昵称写入编辑框。
|
||||
3. 用户提交后,`ProfileViewModel.saveProfile` 校验昵称非空。
|
||||
4. 昵称未变化时直接退出编辑状态。
|
||||
5. 昵称变化时调用 `ProfileAPI.updateUserInfo(nickname:)`。
|
||||
6. 接口成功后本地更新 `userInfo.nickname`。
|
||||
7. `ProfileView` 刷新全局账号资料和账号快照。
|
||||
|
||||
## 密码修改流程
|
||||
|
||||
1. 用户打开 `PasswordUpdateSheet`。
|
||||
2. 输入新密码并点击完成。
|
||||
3. `ProfileViewModel.updatePassword` 校验密码至少 6 位。
|
||||
4. 校验通过后调用 `ProfileAPI.updateUserInfo(password:)`。
|
||||
5. 成功后关闭弹窗并展示 Toast。
|
||||
|
||||
密码不会写入本地缓存。
|
||||
|
||||
## 退出登录流程
|
||||
|
||||
1. 用户点击退出登录。
|
||||
2. `ProfileView` 展示确认弹窗。
|
||||
3. 用户确认后调用 `AuthSessionCoordinator.logout`。
|
||||
4. 协调器清空正式 token、账号快照、账号上下文、路由栈和 Toast。
|
||||
5. `AppSession` 切换为 `loggedOut`,根视图回到登录页。
|
||||
6. 上次手机号和协议状态等非敏感偏好保留。
|
||||
|
||||
## 状态展示规则
|
||||
|
||||
- 昵称为空时展示“未设置昵称”。
|
||||
- 真实姓名、手机号和账号状态为空时展示 `--`。
|
||||
- 实名认证状态:
|
||||
- `auditStatus == 2`:已实名认证。
|
||||
- `auditStatus == 3`:审核不通过。
|
||||
- 其他状态:审核中。
|
||||
- 无实名认证信息时:点击去实名认证。
|
||||
|
||||
157
suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift
Normal file
157
suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift
Normal file
@ -0,0 +1,157 @@
|
||||
//
|
||||
// ProfileViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 个人信息页 ViewModel,负责资料加载、编辑状态和提交更新。
|
||||
final class ProfileViewModel {
|
||||
var userInfo: UserInfoResponse?
|
||||
var realNameInfo: RealNameInfo?
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
var isEditingProfile = false
|
||||
var editingNickname = ""
|
||||
|
||||
var displayNickname: String {
|
||||
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
||||
}
|
||||
|
||||
var displayRealName: String {
|
||||
nonEmpty(userInfo?.realName) ?? "--"
|
||||
}
|
||||
|
||||
var displayPhone: String {
|
||||
nonEmpty(userInfo?.phone) ?? "--"
|
||||
}
|
||||
|
||||
var displayAvatarURL: String {
|
||||
nonEmpty(userInfo?.avatar) ?? ""
|
||||
}
|
||||
|
||||
var accountStatusText: String {
|
||||
nonEmpty(userInfo?.statusName) ?? "--"
|
||||
}
|
||||
|
||||
var realNameStatusText: String {
|
||||
guard let realNameInfo else { return "点击去实名认证" }
|
||||
switch realNameInfo.auditStatus {
|
||||
case 2:
|
||||
return "已实名认证"
|
||||
case 3:
|
||||
return "审核不通过"
|
||||
default:
|
||||
return "审核中"
|
||||
}
|
||||
}
|
||||
|
||||
/// 重新加载用户资料和实名认证状态。
|
||||
func reload(api: ProfileAPI) async throws {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
async let user = api.userInfo()
|
||||
async let realName = api.realNameInfo()
|
||||
let result = try await (user, realName)
|
||||
|
||||
userInfo = result.0
|
||||
realNameInfo = result.1.realNameInfo
|
||||
}
|
||||
|
||||
/// 进入昵称编辑状态,并把当前昵称填入编辑框。
|
||||
func beginEditing() {
|
||||
editingNickname = displayNickname == "未设置昵称" ? "" : displayNickname
|
||||
isEditingProfile = true
|
||||
}
|
||||
|
||||
/// 退出昵称编辑状态并清空编辑输入。
|
||||
func cancelEditing() {
|
||||
editingNickname = ""
|
||||
isEditingProfile = false
|
||||
}
|
||||
|
||||
/// 保存昵称修改,并同步更新本地 userInfo。
|
||||
func saveProfile(api: ProfileAPI) async throws {
|
||||
let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !nextNickname.isEmpty else {
|
||||
throw ProfileValidationError.emptyNickname
|
||||
}
|
||||
|
||||
let nicknameChanged = nextNickname != displayNickname
|
||||
guard nicknameChanged else {
|
||||
cancelEditing()
|
||||
return
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
|
||||
try await api.updateUserInfo(nickname: nextNickname)
|
||||
var nextUser = userInfo ?? UserInfoResponse()
|
||||
nextUser = UserInfoResponse(
|
||||
avatar: nextUser.avatar,
|
||||
realName: nextUser.realName,
|
||||
phone: nextUser.phone,
|
||||
nickname: nextNickname,
|
||||
roleName: nextUser.roleName,
|
||||
status: nextUser.status,
|
||||
statusName: nextUser.statusName
|
||||
)
|
||||
userInfo = nextUser
|
||||
cancelEditing()
|
||||
}
|
||||
|
||||
/// 修改用户密码,并校验最小长度。
|
||||
func updatePassword(_ password: String, api: ProfileAPI) async throws {
|
||||
let nextPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard nextPassword.count >= 6 else {
|
||||
throw ProfileValidationError.shortPassword
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
|
||||
try await api.updateUserInfo(password: nextPassword)
|
||||
}
|
||||
|
||||
/// 将最新 userInfo 合成为全局 AccountProfile,保留已有 userId 等兜底字段。
|
||||
func accountProfileFallback(_ fallback: AccountProfile?) -> AccountProfile? {
|
||||
guard let userInfo else { return fallback }
|
||||
return AccountProfile(
|
||||
userId: fallback?.userId ?? "",
|
||||
displayName: nonEmpty(userInfo.nickname) ?? nonEmpty(userInfo.realName) ?? fallback?.displayName ?? "未设置昵称",
|
||||
phone: nonEmpty(userInfo.phone) ?? fallback?.phone,
|
||||
avatarURL: nonEmpty(userInfo.avatar) ?? fallback?.avatarURL
|
||||
)
|
||||
}
|
||||
|
||||
/// 去除空白字符后返回非空字符串。
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人信息页校验错误实体,表示昵称和密码输入不符合要求。
|
||||
enum ProfileValidationError: LocalizedError {
|
||||
case emptyNickname
|
||||
case shortPassword
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyNickname:
|
||||
"请输入昵称"
|
||||
case .shortPassword:
|
||||
"密码至少 6 位"
|
||||
}
|
||||
}
|
||||
}
|
||||
636
suixinkan/Features/Profile/Views/ProfileView.swift
Normal file
636
suixinkan/Features/Profile/Views/ProfileView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user