Add OSS upload and Kingfisher image support
This commit is contained in:
@ -8,7 +8,7 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括
|
||||
- 拉取用户基础资料。
|
||||
- 拉取实名认证信息。
|
||||
- 展示头像、昵称、UID、手机号、账号状态和实名认证状态。
|
||||
- 修改昵称。
|
||||
- 修改昵称和头像。
|
||||
- 修改密码。
|
||||
- 切换当前景区/门店业务账号。
|
||||
- 提交实名认证基础资料。
|
||||
@ -26,6 +26,7 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括
|
||||
- `RealNameAuthView` / `RealNameAuthViewModel`:实名认证页面和表单逻辑。
|
||||
- `SettingsCenterView` / `AgreementView`:设置中心和协议 H5 页面。
|
||||
- `ProfileAPI`:封装用户资料、实名认证和资料更新接口。
|
||||
- `OSSUploadService`:上传头像和实名认证证件图片,返回可提交给业务接口的 OSS URL。
|
||||
- `UserInfoResponse`:用户基础资料。
|
||||
- `RealNameInfoResponse` / `RealNameInfo`:实名认证信息。
|
||||
- `UpdateInfoRequest`:昵称、密码或头像更新请求体。
|
||||
@ -42,15 +43,18 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括
|
||||
5. `ProfileView` 将最新 `userInfo` 交给 `AuthSessionCoordinator.refreshCachedProfile`。
|
||||
6. 协调器更新 `AccountContext.profile`,并同步刷新账号快照。
|
||||
|
||||
## 昵称编辑流程
|
||||
## 资料编辑流程
|
||||
|
||||
1. 用户点击编辑按钮。
|
||||
2. `ProfileViewModel.beginEditing` 进入编辑状态,并把当前昵称写入编辑框。
|
||||
3. 用户提交后,`ProfileViewModel.saveProfile` 校验昵称非空。
|
||||
4. 昵称未变化时直接退出编辑状态。
|
||||
5. 昵称变化时调用 `ProfileAPI.updateUserInfo(nickname:)`。
|
||||
6. 接口成功后本地更新 `userInfo.nickname`。
|
||||
7. `ProfileView` 刷新全局账号资料和账号快照。
|
||||
3. 用户点击头像时通过 `PhotosPicker` 选择本地图片。
|
||||
4. `AvatarImageProcessor` 将头像压缩为 JPEG,并暂存在内存中。
|
||||
5. 用户提交后,`ProfileViewModel.saveProfile` 校验昵称非空。
|
||||
6. 头像变化时先调用 `OSSUploadService.uploadUserAvatar` 上传到 OSS。
|
||||
7. 头像上传成功后调用 `ProfileAPI.updateUserAvatarURL` 回写头像 URL。
|
||||
8. 昵称变化时调用 `ProfileAPI.updateUserInfo(nickname:)`。
|
||||
9. 接口成功后本地更新 `userInfo.nickname` 和 `userInfo.avatar`。
|
||||
10. `ProfileView` 刷新全局账号资料和账号快照。
|
||||
|
||||
## 密码修改流程
|
||||
|
||||
@ -76,11 +80,15 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括
|
||||
1. 用户点击“认证状态”进入 `RealNameAuthView`。
|
||||
2. `RealNameAuthViewModel.load` 调用 `ProfileAPI.realNameInfo` 并回填姓名、身份证号、证件有效期、图片 URL 和审核状态。
|
||||
3. 用户可发送短信验证码,接口为 `/api/yf-handset-app/photog/real-name/sms-verify-code`。
|
||||
4. 提交前校验姓名、身份证号、短信验证码、证件图片 URL 和有效期。
|
||||
5. 校验通过后调用 `/api/yf-handset-app/photog/real-name/submit`。
|
||||
6. 提交成功后重新加载实名认证信息。
|
||||
4. 用户通过图片卡片选择身份证人像面和国徽面。
|
||||
5. `RealNameImageProcessor` 将证件图片压缩为 JPEG,并暂存在内存中。
|
||||
6. 提交前校验姓名、身份证号、短信验证码、证件图片和有效期。
|
||||
7. 校验通过后先调用 `OSSUploadService.uploadRealNameImage` 上传证件图片。
|
||||
8. 上传成功后把 OSS URL 写入 `RealNameAuthRequest.frontUrl/backUrl`。
|
||||
9. 调用 `/api/yf-handset-app/photog/real-name/submit` 提交实名资料。
|
||||
10. 提交成功后重新加载实名认证信息。
|
||||
|
||||
本轮证件图片先保留 URL 输入和网络预览,阿里云 OSS 图片选择上传后续接入时替换该输入区。
|
||||
证件图片的原始 Data、压缩 Data 和上传进度只保存在内存中,不写入本地缓存。
|
||||
|
||||
## 设置和协议流程
|
||||
|
||||
|
||||
@ -18,6 +18,9 @@ final class ProfileViewModel {
|
||||
var isSaving = false
|
||||
var isEditingProfile = false
|
||||
var editingNickname = ""
|
||||
private(set) var pendingAvatarData: Data?
|
||||
private(set) var pendingAvatarFileName: String?
|
||||
private(set) var avatarUploadProgress: Int?
|
||||
|
||||
var displayNickname: String {
|
||||
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
||||
@ -75,29 +78,51 @@ final class ProfileViewModel {
|
||||
func cancelEditing() {
|
||||
editingNickname = ""
|
||||
isEditingProfile = false
|
||||
clearPendingAvatar()
|
||||
}
|
||||
|
||||
/// 保存昵称修改,并同步更新本地 userInfo。
|
||||
func saveProfile(api: ProfileAPI) async throws {
|
||||
/// 准备待上传头像,并进入资料编辑状态。
|
||||
func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
|
||||
let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp)
|
||||
pendingAvatarData = processed.data
|
||||
pendingAvatarFileName = processed.fileName
|
||||
if !isEditingProfile {
|
||||
beginEditing()
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存昵称和头像修改,并同步更新本地 userInfo。
|
||||
func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !nextNickname.isEmpty else {
|
||||
throw ProfileValidationError.emptyNickname
|
||||
}
|
||||
|
||||
let nicknameChanged = nextNickname != displayNickname
|
||||
guard nicknameChanged else {
|
||||
let avatarChanged = pendingAvatarData != nil
|
||||
guard nicknameChanged || avatarChanged else {
|
||||
cancelEditing()
|
||||
return
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
avatarUploadProgress = avatarChanged ? 1 : nil
|
||||
defer {
|
||||
isSaving = false
|
||||
avatarUploadProgress = nil
|
||||
}
|
||||
|
||||
try await api.updateUserInfo(nickname: nextNickname)
|
||||
let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId)
|
||||
if let uploadedAvatarURL {
|
||||
try await api.updateUserAvatarURL(uploadedAvatarURL)
|
||||
}
|
||||
if nicknameChanged {
|
||||
try await api.updateUserInfo(nickname: nextNickname)
|
||||
}
|
||||
var nextUser = userInfo ?? UserInfoResponse()
|
||||
nextUser = UserInfoResponse(
|
||||
avatar: nextUser.avatar,
|
||||
avatar: uploadedAvatarURL ?? nextUser.avatar,
|
||||
realName: nextUser.realName,
|
||||
phone: nextUser.phone,
|
||||
nickname: nextNickname,
|
||||
@ -106,6 +131,7 @@ final class ProfileViewModel {
|
||||
statusName: nextUser.statusName
|
||||
)
|
||||
userInfo = nextUser
|
||||
clearPendingAvatar()
|
||||
cancelEditing()
|
||||
}
|
||||
|
||||
@ -139,6 +165,24 @@ final class ProfileViewModel {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
/// 上传待保存头像;没有待上传图片时返回 nil。
|
||||
private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? {
|
||||
guard let pendingAvatarData else { return nil }
|
||||
let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg"
|
||||
return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.avatarUploadProgress = progress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空本地待上传头像数据。
|
||||
private func clearPendingAvatar() {
|
||||
pendingAvatarData = nil
|
||||
pendingAvatarFileName = nil
|
||||
avatarUploadProgress = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人信息页校验错误实体,表示昵称和密码输入不符合要求。
|
||||
|
||||
@ -21,6 +21,12 @@ final class RealNameAuthViewModel {
|
||||
var isLongValid = false
|
||||
var frontUrl = ""
|
||||
var backUrl = ""
|
||||
private(set) var pendingFrontImageData: Data?
|
||||
private(set) var pendingBackImageData: Data?
|
||||
private(set) var frontUploadProgress: Int?
|
||||
private(set) var backUploadProgress: Int?
|
||||
private var pendingFrontFileName: String?
|
||||
private var pendingBackFileName: String?
|
||||
private(set) var loading = false
|
||||
private(set) var sendingCode = false
|
||||
private(set) var submitting = false
|
||||
@ -46,8 +52,23 @@ final class RealNameAuthViewModel {
|
||||
statusMessage = "验证码已发送"
|
||||
}
|
||||
|
||||
/// 准备待上传证件图片,并更新对应面的本地预览。
|
||||
func prepareIdentityImage(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
|
||||
let processed = try RealNameImageProcessor.process(data: data, side: side, timestamp: timestamp)
|
||||
switch side {
|
||||
case .front:
|
||||
pendingFrontImageData = processed.data
|
||||
pendingFrontFileName = processed.fileName
|
||||
frontUrl = ""
|
||||
case .back:
|
||||
pendingBackImageData = processed.data
|
||||
pendingBackFileName = processed.fileName
|
||||
backUrl = ""
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交实名认证资料,成功后刷新审核状态。
|
||||
func submit(api: ProfileAPI) async throws {
|
||||
func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
if info?.verified == true {
|
||||
statusMessage = "已完成实名认证"
|
||||
return
|
||||
@ -58,6 +79,7 @@ final class RealNameAuthViewModel {
|
||||
|
||||
submitting = true
|
||||
defer { submitting = false }
|
||||
try await uploadPendingIdentityImages(uploader: uploader, scenicId: scenicId)
|
||||
try await api.realNameSubmit(makeRequest())
|
||||
statusMessage = "已提交审核"
|
||||
try await load(api: api)
|
||||
@ -80,8 +102,8 @@ final class RealNameAuthViewModel {
|
||||
if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" }
|
||||
if info?.verified != true {
|
||||
if smsCode.trimmed.isEmpty { return "请输入短信验证码" }
|
||||
if frontUrl.trimmed.isEmpty { return "请填写身份证人像面图片 URL" }
|
||||
if backUrl.trimmed.isEmpty { return "请填写身份证国徽面图片 URL" }
|
||||
if frontUrl.trimmed.isEmpty && pendingFrontImageData == nil { return "请选择身份证人像面图片" }
|
||||
if backUrl.trimmed.isEmpty && pendingBackImageData == nil { return "请选择身份证国徽面图片" }
|
||||
}
|
||||
if !isLongValid && endDate < startDate { return "证件结束日期不能早于起始日期" }
|
||||
return nil
|
||||
@ -95,6 +117,10 @@ final class RealNameAuthViewModel {
|
||||
idCardNo = info.idCardNo
|
||||
frontUrl = info.frontUrl ?? ""
|
||||
backUrl = info.backUrl ?? ""
|
||||
pendingFrontImageData = nil
|
||||
pendingBackImageData = nil
|
||||
pendingFrontFileName = nil
|
||||
pendingBackFileName = nil
|
||||
isLongValid = info.isLongValid
|
||||
startDate = Self.date(from: info.startDate) ?? startDate
|
||||
endDate = Self.date(from: info.endDate) ?? endDate
|
||||
@ -148,6 +174,41 @@ final class RealNameAuthViewModel {
|
||||
guard let value, !value.isEmpty else { return nil }
|
||||
return dateFormatter.date(from: value)
|
||||
}
|
||||
|
||||
/// 上传待提交证件图片,并把返回 URL 写回表单字段。
|
||||
private func uploadPendingIdentityImages(uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
if let pendingFrontImageData {
|
||||
frontUploadProgress = 1
|
||||
defer { frontUploadProgress = nil }
|
||||
frontUrl = try await uploader.uploadRealNameImage(
|
||||
data: pendingFrontImageData,
|
||||
fileName: pendingFrontFileName ?? "real_name_front_\(Int(Date().timeIntervalSince1970)).jpg",
|
||||
scenicId: scenicId
|
||||
) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.frontUploadProgress = progress
|
||||
}
|
||||
}
|
||||
self.pendingFrontImageData = nil
|
||||
pendingFrontFileName = nil
|
||||
}
|
||||
|
||||
if let pendingBackImageData {
|
||||
backUploadProgress = 1
|
||||
defer { backUploadProgress = nil }
|
||||
backUrl = try await uploader.uploadRealNameImage(
|
||||
data: pendingBackImageData,
|
||||
fileName: pendingBackFileName ?? "real_name_back_\(Int(Date().timeIntervalSince1970)).jpg",
|
||||
scenicId: scenicId
|
||||
) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.backUploadProgress = progress
|
||||
}
|
||||
}
|
||||
self.pendingBackImageData = nil
|
||||
pendingBackFileName = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证校验错误实体,用于把表单错误统一抛给页面展示。
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
/// 构造账号头像占位。
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user