feat: 新增用户信息更新功能

- 在APIEndpoints中新增用户信息更新端点。
- 实现UpdateUserRequest和UpdateUserResponse结构体,支持用户信息更新请求和响应。
- 在APIService中添加updateUser方法,处理用户信息更新请求。
- 更新AppSettingFeature以支持头像和昵称的修改,整合用户信息更新逻辑。
- 在AppSettingView中实现头像选择和昵称编辑功能,提升用户体验。
This commit is contained in:
edwinQQQ
2025-07-24 16:38:27 +08:00
parent c072a7e73d
commit 343fd9e2df
7 changed files with 307 additions and 88 deletions

View File

@@ -24,6 +24,7 @@ enum APIEndpoint: String, CaseIterable {
case publishFeed = "/dynamic/square/publish" // case publishFeed = "/dynamic/square/publish" //
case getUserInfo = "/user/get" // case getUserInfo = "/user/get" //
case getMyDynamic = "/dynamic/getMyDynamic" case getMyDynamic = "/dynamic/getMyDynamic"
case updateUser = "/user/v2/update" //
// Web // Web
case userAgreement = "/modules/rule/protocol.html" case userAgreement = "/modules/rule/protocol.html"

View File

@@ -787,7 +787,7 @@ extension UserInfoManager {
debugInfoSync("🔄 开始刷新当前用户信息") debugInfoSync("🔄 开始刷新当前用户信息")
debugInfoSync(" 当前UID: \(currentUid)") debugInfoSync(" 当前UID: \(currentUid)")
if let userInfo = await fetchUserInfoFromServer(uid: currentUid, apiService: apiService) { if let _ = await fetchUserInfoFromServer(uid: currentUid, apiService: apiService) {
debugInfoSync("✅ 用户信息刷新成功") debugInfoSync("✅ 用户信息刷新成功")
return true return true
} else { } else {
@@ -832,7 +832,7 @@ extension UserInfoManager {
} }
// //
if let cachedUserInfo = await getUserInfo() { if let _ = await getUserInfo() {
debugInfoSync("📱 APP启动使用现有用户信息缓存") debugInfoSync("📱 APP启动使用现有用户信息缓存")
return true return true
} }
@@ -843,3 +843,32 @@ extension UserInfoManager {
} }
} }
// MARK: -
struct UpdateUserRequest: APIRequestProtocol {
typealias Response = UpdateUserResponse
let avatar: String?
let nick: String?
let uid: Int
let ticket: String
var endpoint: String { APIEndpoint.updateUser.path }
var method: HTTPMethod { .POST }
// queryParameters
var queryParameters: [String: String]? {
var params: [String: String] = [
"uid": String(uid),
"ticket": ticket
]
if let avatar = avatar { params["avatar"] = avatar }
if let nick = nick { params["nick"] = nick }
return params
}
var bodyParameters: [String: Any]? { nil }
}
struct UpdateUserResponse: Codable, Equatable {
let code: Int
let message: String
let data: UserInfo?
}

View File

@@ -222,6 +222,11 @@ struct LiveAPIService: APIServiceProtocol, Sendable {
} }
} }
// MARK: -
func updateUser(request: UpdateUserRequest) async throws -> UpdateUserResponse {
try await self.request(request)
}
// MARK: - Private Helper Methods // MARK: - Private Helper Methods
/// URL /// URL

View File

@@ -581,7 +581,7 @@ extension LoginHelper {
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26" let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else { guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
await debugErrorSync("❌ 邮箱DES加密失败") debugErrorSync("❌ 邮箱DES加密失败")
return nil return nil
} }

View File

@@ -14,6 +14,14 @@ struct AppSettingFeature {
// WebView // WebView
var showUserAgreement: Bool = false var showUserAgreement: Bool = false
var showPrivacyPolicy: Bool = false var showPrivacyPolicy: Bool = false
// /
var isUploadingAvatar: Bool = false
var avatarUploadError: String? = nil
var isEditingNickname: Bool = false
var nicknameInput: String = ""
var isUpdatingUser: Bool = false
var updateUserError: String? = nil
} }
enum Action: Equatable { enum Action: Equatable {
@@ -35,89 +43,177 @@ struct AppSettingFeature {
// WebView // WebView
case userAgreementDismissed case userAgreementDismissed
case privacyPolicyDismissed case privacyPolicyDismissed
// /
case avatarTapped
case avatarSelected(Data)
case avatarUploadResult(Result<String, APIError>)
case nicknameEditConfirmed(String)
case updateUser(Result<UpdateUserResponse, APIError>)
case nicknameInputChanged(String)
case nicknameEditAlert(Bool)
} }
@Dependency(\.apiService) var apiService @Dependency(\.apiService) var apiService
var body: some ReducerOf<Self> { func reduce(into state: inout State, action: Action) -> Effect<Action> {
Reduce { state, action in switch action {
switch action { case .onAppear:
case .onAppear: return .send(.loadUserInfo)
return .send(.loadUserInfo)
case .editNicknameTapped:
//
return .none
case .logoutTapped:
//
return .run { send in
await UserInfoManager.clearAllAuthenticationData()
// FeatureMainFeature
// .noneMainFeatureAppSettingFeature.Action.logoutTapped
}
case .editNicknameTapped: case .loadUserInfo:
// state.isLoadingUserInfo = true
return .none state.userInfoError = nil
return .run { send in
case .logoutTapped: do {
// if let userInfo = await UserInfoManager.getUserInfo() {
return .run { send in await send(.userInfoResponse(.success(userInfo)))
await UserInfoManager.clearAllAuthenticationData() } else {
// FeatureMainFeature await send(.userInfoResponse(.failure(APIError.custom("用户信息不存在"))))
// .noneMainFeatureAppSettingFeature.Action.logoutTapped }
} catch {
let apiError: APIError
if let error = error as? APIError {
apiError = error
} else {
apiError = APIError.custom(error.localizedDescription)
}
await send(.userInfoResponse(.failure(apiError)))
} }
}
case .loadUserInfo:
state.isLoadingUserInfo = true case let .userInfoResponse(.success(userInfo)):
state.userInfoError = nil state.userInfo = userInfo
state.nickname = userInfo.nick ?? ""
state.avatarURL = userInfo.avatar
state.isLoadingUserInfo = false
return .none
case let .userInfoResponse(.failure(error)):
state.userInfoError = error.localizedDescription
state.isLoadingUserInfo = false
return .none
case .personalInfoPermissionsTapped:
state.showPrivacyPolicy = true
return .none
case .helpTapped:
state.showUserAgreement = true
return .none
case .clearCacheTapped:
//
return .none
case .checkUpdatesTapped:
//
return .none
case .aboutUsTapped:
//
return .none
case .userAgreementDismissed:
state.showUserAgreement = false
return .none
case .privacyPolicyDismissed:
state.showPrivacyPolicy = false
return .none
case .avatarTapped:
//
return .none
case let .avatarSelected(imageData):
state.isUploadingAvatar = true
state.avatarUploadError = nil
return .run { [avatarData = imageData] send in
guard let uiImage = UIImage(data: avatarData) else {
await send(.avatarUploadResult(.failure(APIError.custom("图片格式错误"))))
return
}
//
if let url = await COSManager.shared.uploadUIImage(uiImage, apiService: apiService) {
await send(.avatarUploadResult(.success(url)))
} else {
await send(.avatarUploadResult(.failure(APIError.custom("头像上传失败"))))
}
}
case let .avatarUploadResult(.success(url)):
state.isUploadingAvatar = false
// updateUser API avatar
state.isUpdatingUser = true
state.updateUserError = nil
guard let userInfo = state.userInfo else { return .none }
return .run { send in
let ticket = await UserInfoManager.getCurrentUserTicket() ?? ""
let req = UpdateUserRequest(avatar: url, nick: nil, uid: userInfo.uid ?? 0, ticket: ticket)
do {
let resp: UpdateUserResponse = try await apiService.request(req)
await send(.updateUser(.success(resp)))
} catch {
let apiError = error as? APIError ?? APIError.custom(error.localizedDescription)
await send(.updateUser(.failure(apiError)))
}
}
case let .avatarUploadResult(.failure(error)):
state.isUploadingAvatar = false
state.avatarUploadError = error.localizedDescription
return .none
case .nicknameEditAlert(let show):
state.isEditingNickname = show
state.nicknameInput = state.nickname
return .none
case .nicknameInputChanged(let text):
state.nicknameInput = String(text.prefix(15))
return .none
case .nicknameEditConfirmed(let newNick):
guard let userInfo = state.userInfo else { return .none }
state.isUpdatingUser = true
state.updateUserError = nil
return .run { send in
let ticket = await UserInfoManager.getCurrentUserTicket() ?? ""
let req = UpdateUserRequest(avatar: nil, nick: newNick, uid: userInfo.uid ?? 0, ticket: ticket)
do {
let resp: UpdateUserResponse = try await apiService.request(req)
await send(.updateUser(.success(resp)))
} catch {
let apiError = error as? APIError ?? APIError.custom(error.localizedDescription)
await send(.updateUser(.failure(apiError)))
}
}
case let .updateUser(.success(resp)):
state.isUpdatingUser = false
// resp.data userinfo1
if let uid = state.userInfo?.uid {
return .run { send in return .run { send in
do { try? await Task.sleep(nanoseconds: 1_000_000_000)
if let userInfo = await UserInfoManager.getUserInfo() { if let newUser = await UserInfoManager.fetchUserInfoFromServer(uid: String(uid), apiService: apiService) {
await send(.userInfoResponse(.success(userInfo))) await send(.userInfoResponse(.success(newUser)))
} else { } else {
await send(.userInfoResponse(.failure(APIError.custom("用户信息不存在")))) await send(.userInfoResponse(.failure(APIError.custom("获取最新用户信息失败"))))
}
} catch {
let apiError: APIError
if let error = error as? APIError {
apiError = error
} else {
apiError = APIError.custom(error.localizedDescription)
}
await send(.userInfoResponse(.failure(apiError)))
} }
} }
case let .userInfoResponse(.success(userInfo)):
state.userInfo = userInfo
state.nickname = userInfo.nick ?? ""
state.avatarURL = userInfo.avatar
state.isLoadingUserInfo = false
return .none
case let .userInfoResponse(.failure(error)):
state.userInfoError = error.localizedDescription
state.isLoadingUserInfo = false
return .none
case .personalInfoPermissionsTapped:
state.showPrivacyPolicy = true
return .none
case .helpTapped:
state.showUserAgreement = true
return .none
case .clearCacheTapped:
//
return .none
case .checkUpdatesTapped:
//
return .none
case .aboutUsTapped:
//
return .none
case .userAgreementDismissed:
state.showUserAgreement = false
return .none
case .privacyPolicyDismissed:
state.showPrivacyPolicy = false
return .none
} }
state.isEditingNickname = false
return .none
case let .updateUser(.failure(error)):
state.isUpdatingUser = false
state.updateUserError = error.localizedDescription
return .none
} }
} }
} }

View File

@@ -2,11 +2,13 @@ import Foundation
import ComposableArchitecture import ComposableArchitecture
import CasePaths import CasePaths
struct MainFeature: Reducer { @Reducer
struct MainFeature {
enum Tab: Int, Equatable, CaseIterable { enum Tab: Int, Equatable, CaseIterable {
case feed, other case feed, other
} }
@ObservableState
struct State: Equatable { struct State: Equatable {
var selectedTab: Tab = .feed var selectedTab: Tab = .feed
var feedList: FeedListFeature.State = .init() var feedList: FeedListFeature.State = .init()
@@ -42,10 +44,10 @@ struct MainFeature: Reducer {
} }
var body: some ReducerOf<Self> { var body: some ReducerOf<Self> {
Scope(state: \.feedList, action: \.feedList) { Scope(state: \ .feedList, action: \ .feedList) {
FeedListFeature() FeedListFeature()
} }
Scope(state: \.me, action: \.me) { Scope(state: \ .me, action: \ .me) {
MeFeature() MeFeature()
} }
Reduce { state, action in Reduce { state, action in
@@ -104,7 +106,7 @@ struct MainFeature: Reducer {
} }
} }
// //
.ifLet(\ .appSettingState, action: \.appSettingAction) { .ifLet(\ .appSettingState, action: \ .appSettingAction) {
AppSettingFeature() AppSettingFeature()
} }
} }

View File

@@ -1,31 +1,51 @@
import SwiftUI import SwiftUI
import ComposableArchitecture import ComposableArchitecture
import PhotosUI
struct AppSettingView: View { struct AppSettingView: View {
let store: StoreOf<AppSettingFeature> let store: StoreOf<AppSettingFeature>
@State private var showPhotoPicker = false
@State private var selectedPhotoItem: PhotosPickerItem? = nil
@State private var showNicknameAlert = false
@State private var nicknameInput: String = ""
var body: some View { var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in WithViewStore(self.store, observe: { $0 }) { viewStore in
ZStack { ZStack {
Color(red: 22/255, green: 17/255, blue: 44/255).ignoresSafeArea() Color(red: 22/255, green: 17/255, blue: 44/255).ignoresSafeArea()
// VStack(spacing: 0) { VStack(spacing: 0) {
// //
VStack(spacing: 0) { VStack(spacing: 0) {
// //
avatarSection(viewStore: viewStore) avatarSection(viewStore: viewStore)
// //
nicknameSection(viewStore: viewStore) nicknameSection(viewStore: viewStore)
// //
settingsSection(viewStore: viewStore) settingsSection(viewStore: viewStore)
Spacer() Spacer()
// //
logoutButton(viewStore: viewStore) logoutButton(viewStore: viewStore)
} }
// } }
// Loading &
if viewStore.isUploadingAvatar || viewStore.isUpdatingUser {
Color.black.opacity(0.3).ignoresSafeArea()
ProgressView("正在上传/更新...")
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.foregroundColor(.white)
.scaleEffect(1.2)
}
if let error = viewStore.avatarUploadError ?? viewStore.updateUserError {
VStack {
Spacer()
Text(error)
.foregroundColor(.red)
.padding()
.background(Color.white.opacity(0.9))
.cornerRadius(10)
Spacer()
}
}
} }
.navigationTitle(NSLocalizedString("appSetting.title", comment: "Edit")) .navigationTitle(NSLocalizedString("appSetting.title", comment: "Edit"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -51,6 +71,35 @@ struct AppSettingView: View {
isPresented: privacyPolicyBinding(viewStore: viewStore), isPresented: privacyPolicyBinding(viewStore: viewStore),
url: APIConfiguration.webURL(for: .privacyPolicy) url: APIConfiguration.webURL(for: .privacyPolicy)
) )
//
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images, photoLibrary: .shared())
.onChange(of: selectedPhotoItem) { newItem in
if let item = newItem {
loadAndProcessImage(item: item) { data in
if let data = data {
viewStore.send(.avatarSelected(data))
}
}
}
}
// Alert
.alert("修改昵称", isPresented: $showNicknameAlert, actions: {
TextField("请输入昵称", text: $nicknameInput)
.onChange(of: nicknameInput) { newValue in
if newValue.count > 15 {
nicknameInput = String(newValue.prefix(15))
}
}
Button("确定") {
let trimmed = nicknameInput.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty && trimmed != viewStore.nickname {
viewStore.send(.nicknameEditConfirmed(trimmed))
}
}
Button("取消", role: .cancel) {}
}, message: {
Text("昵称最长15个字符")
})
} }
} }
@@ -60,7 +109,13 @@ struct AppSettingView: View {
private func avatarSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View { private func avatarSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
avatarImageView(viewStore: viewStore) avatarImageView(viewStore: viewStore)
.onTapGesture {
showPhotoPicker = true
}
cameraButton cameraButton
.onTapGesture {
showPhotoPicker = true
}
} }
.padding(.top, 24) .padding(.top, 24)
} }
@@ -141,7 +196,8 @@ struct AppSettingView: View {
.padding(.horizontal, 32) .padding(.horizontal, 32)
.padding(.vertical, 18) .padding(.vertical, 18)
.onTapGesture { .onTapGesture {
viewStore.send(.editNicknameTapped) nicknameInput = viewStore.nickname
showNicknameAlert = true
} }
Divider().background(Color.gray.opacity(0.3)) Divider().background(Color.gray.opacity(0.3))
@@ -255,4 +311,34 @@ struct AppSettingView: View {
send: AppSettingFeature.Action.privacyPolicyDismissed send: AppSettingFeature.Action.privacyPolicyDismissed
) )
} }
// MARK: -
private func loadAndProcessImage(item: PhotosPickerItem, completion: @escaping (Data?) -> Void) {
item.loadTransferable(type: Data.self) { result in
guard let data = try? result.get(), let uiImage = UIImage(data: data) else {
completion(nil)
return
}
let square = cropToSquare(image: uiImage)
let resized = resizeImage(image: square, targetSize: CGSize(width: 180, height: 180))
let jpegData = resized.jpegData(compressionQuality: 0.8)
completion(jpegData)
}
}
}
// MARK: -
private func cropToSquare(image: UIImage) -> UIImage {
let size = min(image.size.width, image.size.height)
let x = (image.size.width - size) / 2
let y = (image.size.height - size) / 2
let cropRect = CGRect(x: x, y: y, width: size, height: size)
guard let cgImage = image.cgImage?.cropping(to: cropRect) else { return image }
return UIImage(cgImage: cgImage, scale: image.scale, orientation: image.imageOrientation)
}
private func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: targetSize))
}
} }