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:
parent
942c3e8454
commit
1a88aaeecb
@ -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?
|
||||
}
|
||||
|
||||
62
AIStudyApp/AIStudyApp/Core/Services/FileUploadService.swift
Normal file
62
AIStudyApp/AIStudyApp/Core/Services/FileUploadService.swift
Normal 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 "上传失败,请重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user