新增 OSS 上传与 Kingfisher 图片加载支持

This commit is contained in:
2026-06-22 16:30:05 +08:00
parent 08465fffde
commit 726f19c124
20 changed files with 1399 additions and 134 deletions

View File

@ -156,21 +156,11 @@ struct AccountSwitchView: View {
@ViewBuilder
///
private func accountAvatar(_ account: AccountSwitchAccount) -> some View {
if let url = URL(string: account.avatar), !account.avatar.isEmpty {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
avatarFallback(account)
}
}
.frame(width: 50, height: 50)
.clipShape(Circle())
} else {
RemoteImage(urlString: account.avatar) {
avatarFallback(account)
.frame(width: 50, height: 50)
}
.frame(width: 50, height: 50)
.clipShape(Circle())
}
///

View File

@ -5,7 +5,9 @@
// Created by Codex on 2026/6/20.
//
import PhotosUI
import SwiftUI
import UIKit
/// 退
struct ProfileView: View {
@ -16,6 +18,7 @@ struct ProfileView: View {
@Environment(AppRouter.self) private var appRouter
@Environment(RouterPath.self) private var router
@Environment(ProfileAPI.self) private var profileAPI
@Environment(OSSUploadService.self) private var ossUploadService
@Environment(ToastCenter.self) private var toastCenter
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@ -23,6 +26,7 @@ struct ProfileView: View {
@State private var viewModel = ProfileViewModel()
@State private var showsPasswordSheet = false
@State private var showsLogoutConfirm = false
@State private var pickedAvatarItem: PhotosPickerItem?
@FocusState private var nicknameFocused: Bool
private var contentMaxWidth: CGFloat {
@ -83,6 +87,9 @@ struct ProfileView: View {
nicknameFocused = true
}
}
.onChange(of: pickedAvatarItem) { _, item in
Task { await prepareAvatarImage(from: item) }
}
}
private var profileBackground: some View {
@ -201,10 +208,8 @@ struct ProfileView: View {
}
private var avatarImage: some View {
Button {
toastCenter.show("头像上传待接入阿里云 OSS")
} label: {
AsyncAvatarImage(urlString: viewModel.displayAvatarURL)
PhotosPicker(selection: $pickedAvatarItem, matching: .images) {
avatarPreview
.frame(width: 86, height: 86)
.clipShape(Circle())
.overlay {
@ -221,11 +226,34 @@ struct ProfileView: View {
Circle().stroke(.white, lineWidth: 2)
}
}
.overlay {
if let progress = viewModel.avatarUploadProgress {
ZStack {
Circle()
.fill(.black.opacity(0.34))
Text("\(progress)%")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
}
}
}
}
.buttonStyle(.plain)
.disabled(viewModel.isSaving)
.accessibilityLabel("头像")
}
@ViewBuilder
private var avatarPreview: some View {
if let data = viewModel.pendingAvatarData, let image = UIImage(data: data) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
RemoteAvatarImage(urlString: viewModel.displayAvatarURL)
}
}
private var infoCard: some View {
VStack(spacing: 0) {
infoRow(title: "姓名") {
@ -503,7 +531,11 @@ struct ProfileView: View {
///
private func saveProfileEdits() async {
do {
try await viewModel.saveProfile(api: profileAPI)
try await viewModel.saveProfile(
api: profileAPI,
uploader: ossUploadService,
scenicId: accountContext.currentScenic?.id ?? 0
)
if let userInfo = viewModel.userInfo {
authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext)
} else {
@ -515,6 +547,21 @@ struct ProfileView: View {
}
}
///
private func prepareAvatarImage(from item: PhotosPickerItem?) async {
guard let item else { return }
defer { pickedAvatarItem = nil }
do {
guard let data = try await item.loadTransferable(type: Data.self) else {
toastCenter.show("请选择有效的头像图片")
return
}
try viewModel.prepareAvatarImage(data: data)
} catch {
toastCenter.show(error.localizedDescription)
}
}
///
private func updatePassword(_ password: String) async {
do {
@ -533,38 +580,6 @@ struct ProfileView: View {
}
}
///
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
@ -643,6 +658,7 @@ private struct PasswordUpdateSheet: View {
.environment(AppRouter())
.environment(ToastCenter())
.environment(ProfileAPI(client: APIClient()))
.environment(OSSUploadService(configService: UploadAPI(client: APIClient())))
.environment(AuthSessionCoordinator())
}
}

View File

@ -5,13 +5,19 @@
// Created by Codex on 2026/6/22.
//
import PhotosUI
import SwiftUI
import UIKit
///
struct RealNameAuthView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(ProfileAPI.self) private var profileAPI
@Environment(OSSUploadService.self) private var ossUploadService
@Environment(ToastCenter.self) private var toastCenter
@State private var viewModel = RealNameAuthViewModel()
@State private var pickedFrontItem: PhotosPickerItem?
@State private var pickedBackItem: PhotosPickerItem?
var body: some View {
ScrollView {
@ -39,6 +45,12 @@ struct RealNameAuthView: View {
.background(Color.white.opacity(0.35))
}
}
.onChange(of: pickedFrontItem) { _, item in
Task { await prepareIdentityImage(from: item, side: .front) }
}
.onChange(of: pickedBackItem) { _, item in
Task { await prepareIdentityImage(from: item, side: .back) }
}
}
private var auditStatusPanel: some View {
@ -69,16 +81,28 @@ struct RealNameAuthView: View {
private var identityCard: some View {
VStack(alignment: .leading, spacing: 14) {
identityImageSection(title: "身份证国徽面", url: viewModel.backUrl)
identityImageSection(title: "身份证人像", url: viewModel.frontUrl)
identityImageSection(
title: "身份证国徽",
side: .back,
pickedItem: $pickedBackItem,
pendingData: viewModel.pendingBackImageData,
remoteURL: viewModel.backUrl,
progress: viewModel.backUploadProgress
)
identityImageSection(
title: "身份证人像面",
side: .front,
pickedItem: $pickedFrontItem,
pendingData: viewModel.pendingFrontImageData,
remoteURL: viewModel.frontUrl,
progress: viewModel.frontUploadProgress
)
formField("姓名", text: realNameBinding, placeholder: "请输入姓名")
formField("身份证号码", text: idCardNoBinding, placeholder: "请输入身份证号码")
validitySection
if shouldShowSmsSection {
smsSection
}
urlField("身份证人像面图片 URL", text: frontURLBinding)
urlField("身份证国徽面图片 URL", text: backURLBinding)
submitButton
statusBanner
}
@ -88,12 +112,23 @@ struct RealNameAuthView: View {
}
///
private func identityImageSection(title: String, url: String) -> some View {
private func identityImageSection(
title: String,
side: RealNameImageSide,
pickedItem: Binding<PhotosPickerItem?>,
pendingData: Data?,
remoteURL: String,
progress: Int?
) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(Color(hex: 0x222222))
imagePreview(url: url)
PhotosPicker(selection: pickedItem, matching: .images) {
imagePreview(side: side, pendingData: pendingData, remoteURL: remoteURL, progress: progress)
}
.buttonStyle(.plain)
.disabled(viewModel.submitting || viewModel.info?.verified == true)
}
}
@ -237,21 +272,27 @@ struct RealNameAuthView: View {
}
///
private func imagePreview(url: String) -> some View {
private func imagePreview(side: RealNameImageSide, pendingData: Data?, remoteURL: String, progress: Int?) -> some View {
ZStack {
if let imageURL = URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)), !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
AsyncImage(url: imageURL) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFit()
case .empty:
ProgressView().tint(AppDesign.primary)
default:
placeholderImageContent
}
}
if let pendingData, let image = UIImage(data: pendingData) {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
placeholderImageContent
RemoteImage(urlString: remoteURL, contentMode: .fit) {
placeholderImageContent(side: side)
}
}
if let progress {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(.black.opacity(0.32))
Text("\(progress)%")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
}
}
}
.frame(maxWidth: .infinity)
@ -264,12 +305,18 @@ struct RealNameAuthView: View {
)
}
private var placeholderImageContent: some View {
///
private func placeholderImageContent(side: RealNameImageSide) -> some View {
ZStack {
Color(hex: 0xF3F6FA)
Image(systemName: "photo")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(Color(hex: 0x8A94A6))
VStack(spacing: 8) {
Image(systemName: "photo.badge.plus")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(Color(hex: 0x8A94A6))
Text(side == .front ? "选择身份证人像面" : "选择身份证国徽面")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Color(hex: 0x8A94A6))
}
}
}
@ -286,11 +333,6 @@ struct RealNameAuthView: View {
}
}
/// URL
private func urlField(_ title: String, text: Binding<String>) -> some View {
formField(title, text: text, placeholder: "OSS 上传接入前可先填写图片 URL")
}
///
private func dateField(selection: Binding<Date>) -> some View {
DatePicker("", selection: selection, displayedComponents: .date)
@ -327,14 +369,6 @@ struct RealNameAuthView: View {
Binding(get: { viewModel.smsCode }, set: { viewModel.smsCode = $0 })
}
private var frontURLBinding: Binding<String> {
Binding(get: { viewModel.frontUrl }, set: { viewModel.frontUrl = $0 })
}
private var backURLBinding: Binding<String> {
Binding(get: { viewModel.backUrl }, set: { viewModel.backUrl = $0 })
}
private var startDateBinding: Binding<Date> {
Binding(get: { viewModel.startDate }, set: { viewModel.startDate = $0 })
}
@ -366,12 +400,38 @@ struct RealNameAuthView: View {
///
private func submit() async {
do {
try await viewModel.submit(api: profileAPI)
try await viewModel.submit(
api: profileAPI,
uploader: ossUploadService,
scenicId: accountContext.currentScenic?.id ?? 0
)
} catch {
viewModel.statusMessage = error.localizedDescription
toastCenter.show(error.localizedDescription)
}
}
///
private func prepareIdentityImage(from item: PhotosPickerItem?, side: RealNameImageSide) async {
guard let item else { return }
defer {
switch side {
case .front:
pickedFrontItem = nil
case .back:
pickedBackItem = nil
}
}
do {
guard let data = try await item.loadTransferable(type: Data.self) else {
toastCenter.show("请选择有效的证件图片")
return
}
try viewModel.prepareIdentityImage(data: data, side: side)
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
private extension String {