diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift index 294208d..4c8b58c 100644 --- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift +++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift @@ -378,3 +378,46 @@ struct GenericSuccessResponse: Codable { let success: Bool? let message: String? } + +// MARK: - File Upload (COS presigned URL flow) + +struct FileUploadUrlRequest: Codable { + let filename: String + let mimeType: String + let sizeBytes: Int +} + +struct FileUploadUrlResponse: Codable { + let uploadUrl: String + let objectKey: String + let bucket: String + let region: String + let expiresIn: Int +} + +struct FileConfirmUploadRequest: Codable { + let objectKey: String + let checksum: String? +} + +struct FileConfirmUploadResponse: Codable { + let id: String + let filename: String + let objectKey: String + let sizeBytes: Int +} + +struct FileDetailResponse: Codable { + let file: FileInfo + let downloadUrl: String +} + +struct FileInfo: Codable { + let id: String + let filename: String + let mimeType: String? + let sizeBytes: Int + let objectKey: String? + let storagePath: String? + let createdAt: String? +} diff --git a/AIStudyApp/AIStudyApp/Core/Services/FileUploadService.swift b/AIStudyApp/AIStudyApp/Core/Services/FileUploadService.swift new file mode 100644 index 0000000..4d2500f --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Services/FileUploadService.swift @@ -0,0 +1,62 @@ +import Foundation +import UIKit + +@MainActor +class FileUploadService { + static let shared = FileUploadService() + private let client = APIClient.shared + + /// 上传图片到 COS 并返回文件 ID + func uploadImage(_ image: UIImage, filename: String = "avatar.jpg") async throws -> String { + // 压缩为 JPEG,最大 512KB + guard let imageData = image.jpegData(compressionQuality: 0.7) else { + throw FileUploadError.compressFailed + } + + // 请求预签名上传 URL + let urlReq = FileUploadUrlRequest( + filename: filename, + mimeType: "image/jpeg", + sizeBytes: imageData.count + ) + let urlResp: FileUploadUrlResponse = try await client.request( + "/files/upload-url", method: "POST", body: urlReq + ) + + // 直接 PUT 到 COS + var request = URLRequest(url: URL(string: urlResp.uploadUrl)!) + request.httpMethod = "PUT" + request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type") + request.httpBody = imageData + let (_, response) = try await URLSession.shared.data(for: request) + guard let httpResp = response as? HTTPURLResponse, httpResp.statusCode == 200 else { + throw FileUploadError.uploadFailed + } + + // 确认上传 + let confirmResp: FileConfirmUploadResponse = try await client.request( + "/files/confirm-upload", method: "POST", + body: FileConfirmUploadRequest(objectKey: urlResp.objectKey, checksum: nil) + ) + + return confirmResp.id + } + + /// 获取文件下载 URL + func getDownloadUrl(fileId: String) async throws -> String { + let detail: FileDetailResponse = try await client.request("/files/\(fileId)") + return detail.downloadUrl + } +} + +enum FileUploadError: LocalizedError { + case compressFailed + case uploadFailed + + var errorDescription: String? { + switch self { + case .compressFailed: return "图片压缩失败" + case .uploadFailed: return "上传失败,请重试" + } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift b/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift index 97b4a97..100dae7 100644 --- a/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift +++ b/AIStudyApp/AIStudyApp/Features/Profile/EditProfilePage.swift @@ -1,4 +1,5 @@ import SwiftUI +import PhotosUI struct EditProfilePage: View { @StateObject private var viewModel = ProfileViewModel() @@ -10,12 +11,53 @@ struct EditProfilePage: View { @State private var saved = false @State private var isSaving = false @State private var saveError: String? + @State private var avatarUrl: String? + @State private var isUploadingAvatar = false + @State private var selectedPhotoItem: PhotosPickerItem? + @State private var showPhotoPicker = false var body: some View { ZStack { Color.zxBg0.ignoresSafeArea() ScrollView { VStack(spacing: 16) { + // MARK: - Avatar + VStack(spacing: 12) { + ZStack { + if let url = avatarUrl, let imageUrl = URL(string: url) { + AsyncImage(url: imageUrl) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + case .failure: + defaultAvatar + default: + defaultAvatar + } + } + .frame(width: 88, height: 88) + .clipShape(Circle()) + } else { + defaultAvatar + } + + if isUploadingAvatar { + Circle().fill(Color.black.opacity(0.5)).frame(width: 88, height: 88) + ProgressView().tint(.white) + } + } + + Button { + showPhotoPicker = true + } label: { + Text(isUploadingAvatar ? "上传中..." : "更换头像") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.zxPrimary) + } + .disabled(isUploadingAvatar) + } + .padding(.top, 8) + sectionHeader("基本信息") VStack(spacing: 0) { ZXEditField(title: "昵称", text: $nickname, placeholder: "你的昵称") @@ -64,7 +106,7 @@ struct EditProfilePage: View { saveError = nil do { _ = try await UserService.shared.updateProfile(UpdateProfileRequest( - nickname: nickname.isEmpty ? nil : nickname, avatarUrl: nil + nickname: nickname.isEmpty ? nil : nickname, avatarUrl: avatarUrl )) _ = try await UserService.shared.updateProfileDetail(UpdateProfileDataRequest( learningIdentity: learningIdentity.isEmpty ? nil : learningIdentity, @@ -101,9 +143,15 @@ struct EditProfilePage: View { .navigationTitle("编辑资料") .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.hidden, for: .navigationBar) + .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images) + .onChange(of: selectedPhotoItem) { _, item in + guard let item else { return } + Task { await uploadAvatar(item) } + } .task { await viewModel.loadProfile() nickname = viewModel.userProfile?.nickname ?? "" + avatarUrl = viewModel.userProfile?.avatarUrl learningIdentity = viewModel.profileData?.learningIdentity ?? "" learningDirection = viewModel.profileData?.learningDirection ?? "" bio = viewModel.profileData?.bio ?? "" @@ -111,6 +159,57 @@ struct EditProfilePage: View { } } + private var defaultAvatar: some View { + ZStack { + Circle().fill(Color.zxPurpleBG(0.2)).frame(width: 88, height: 88) + Image(systemName: "person.crop.circle.fill") + .font(.system(size: 40)) + .foregroundColor(Color.zxPurple) + } + } + + private func uploadAvatar(_ item: PhotosPickerItem) async { + isUploadingAvatar = true + defer { isUploadingAvatar = false; selectedPhotoItem = nil } + + guard let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) else { return } + + // 裁剪为正方形 + let size = min(image.size.width, image.size.height) + let rect = CGRect( + x: (image.size.width - size) / 2, + y: (image.size.height - size) / 2, + width: size, height: size + ) + guard let cropped = image.cgImage?.cropping(to: rect) else { return } + let square = UIImage(cgImage: cropped) + + // 缩放到 256x256 + let resized = resizeImage(square, targetSize: CGSize(width: 256, height: 256)) + + do { + let fileId = try await FileUploadService.shared.uploadImage(resized, filename: "avatar.jpg") + let downloadUrl = try await FileUploadService.shared.getDownloadUrl(fileId: fileId) + avatarUrl = downloadUrl + // 立刻更新头像到服务器 + _ = try await UserService.shared.updateProfile(UpdateProfileRequest( + nickname: nickname.isEmpty ? nil : nickname, + avatarUrl: downloadUrl + )) + ZXToastManager.shared.success("头像已更新") + } catch { + ZXToastManager.shared.error(error.localizedDescription) + } + } + + 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)) + } + } + private func sectionHeader(_ text: String) -> some View { Text(text).font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5).padding(.top, 4) } diff --git a/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift b/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift index 1b7d860..0202158 100644 --- a/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift +++ b/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift @@ -51,12 +51,37 @@ struct ProfileView: View { .task { await viewModel.loadAll() } .navigationDestination(for: Route.self) { $0.destination } } + + private var defaultAvatarIcon: some View { + ZStack { + Circle().fill(Color.zxPurpleBG(0.2)).frame(width: 80, height: 80) + Image(systemName: "person.crop.circle.fill") + .font(.system(size: 36)) + .foregroundColor(Color.zxPurple) + } + } + private var profileCard: some View { let profile = viewModel.userProfile return NavigationLink(value: Route.editProfile) { VStack(spacing: 16) { HStack { - ZStack { Circle().frame(width: 80, height: 80).foregroundColor(Color.zxPurpleBG(0.2)); Image(systemName: "person.crop.circle.fill").font(.system(size: 36)).foregroundColor(Color.zxPurple) } + ZStack { + if let urlStr = profile?.avatarUrl, let url = URL(string: urlStr) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): + img.resizable().scaledToFill() + default: + defaultAvatarIcon + } + } + .frame(width: 80, height: 80) + .clipShape(Circle()) + } else { + defaultAvatarIcon + } + } VStack(alignment: .leading, spacing: 4) { Text(profile?.nickname ?? "学习者").font(.system(size: 20, weight: .bold)).foregroundColor(Color.zxF0) Text(profile?.email ?? "").font(.system(size: 12)).foregroundColor(Color.zxF04)