feat(ios): 头像上传功能接入 COS

- 新增 FileUploadService:获取预签名URL → PUT到COS → confirm → 拿下载链接
- 新增 FileUploadUrlRequest/Response 等文件上传相关模型
- EditProfilePage 新增头像区域:
  - PhotosPicker 选择照片
  - 正方形裁剪 + 缩放到 256x256
  - 上传中显示 ProgressView
  - 上传完成后自动更新 profile avatarUrl
- ProfileView 支持显示真实头像图片(AsyncImage)
- 保存时携带 avatarUrl 不再写死 nil

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-05-27 21:44:07 +08:00
parent 942c3e8454
commit 1a88aaeecb
4 changed files with 231 additions and 2 deletions

View File

@ -378,3 +378,46 @@ struct GenericSuccessResponse: Codable {
let success: Bool? let success: Bool?
let message: String? 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?
}

View File

@ -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 "上传失败,请重试"
}
}
}

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import PhotosUI
struct EditProfilePage: View { struct EditProfilePage: View {
@StateObject private var viewModel = ProfileViewModel() @StateObject private var viewModel = ProfileViewModel()
@ -10,12 +11,53 @@ struct EditProfilePage: View {
@State private var saved = false @State private var saved = false
@State private var isSaving = false @State private var isSaving = false
@State private var saveError: String? @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 { var body: some View {
ZStack { ZStack {
Color.zxBg0.ignoresSafeArea() Color.zxBg0.ignoresSafeArea()
ScrollView { ScrollView {
VStack(spacing: 16) { 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("基本信息") sectionHeader("基本信息")
VStack(spacing: 0) { VStack(spacing: 0) {
ZXEditField(title: "昵称", text: $nickname, placeholder: "你的昵称") ZXEditField(title: "昵称", text: $nickname, placeholder: "你的昵称")
@ -64,7 +106,7 @@ struct EditProfilePage: View {
saveError = nil saveError = nil
do { do {
_ = try await UserService.shared.updateProfile(UpdateProfileRequest( _ = 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( _ = try await UserService.shared.updateProfileDetail(UpdateProfileDataRequest(
learningIdentity: learningIdentity.isEmpty ? nil : learningIdentity, learningIdentity: learningIdentity.isEmpty ? nil : learningIdentity,
@ -101,9 +143,15 @@ struct EditProfilePage: View {
.navigationTitle("编辑资料") .navigationTitle("编辑资料")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar) .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 { .task {
await viewModel.loadProfile() await viewModel.loadProfile()
nickname = viewModel.userProfile?.nickname ?? "" nickname = viewModel.userProfile?.nickname ?? ""
avatarUrl = viewModel.userProfile?.avatarUrl
learningIdentity = viewModel.profileData?.learningIdentity ?? "" learningIdentity = viewModel.profileData?.learningIdentity ?? ""
learningDirection = viewModel.profileData?.learningDirection ?? "" learningDirection = viewModel.profileData?.learningDirection ?? ""
bio = viewModel.profileData?.bio ?? "" 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 { private func sectionHeader(_ text: String) -> some View {
Text(text).font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5).padding(.top, 4) Text(text).font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5).padding(.top, 4)
} }

View File

@ -51,12 +51,37 @@ struct ProfileView: View {
.task { await viewModel.loadAll() } .task { await viewModel.loadAll() }
.navigationDestination(for: Route.self) { $0.destination } .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 { private var profileCard: some View {
let profile = viewModel.userProfile let profile = viewModel.userProfile
return NavigationLink(value: Route.editProfile) { return NavigationLink(value: Route.editProfile) {
VStack(spacing: 16) { VStack(spacing: 16) {
HStack { 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) { VStack(alignment: .leading, spacing: 4) {
Text(profile?.nickname ?? "学习者").font(.system(size: 20, weight: .bold)).foregroundColor(Color.zxF0) Text(profile?.nickname ?? "学习者").font(.system(size: 20, weight: .bold)).foregroundColor(Color.zxF0)
Text(profile?.email ?? "").font(.system(size: 12)).foregroundColor(Color.zxF04) Text(profile?.email ?? "").font(.system(size: 12)).foregroundColor(Color.zxF04)