引入带引用计数的 GlobalLoadingCenter,通过 RootView 及主要登录/订单/个人中心页面接入,并添加 Loading 动画资源与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
441 lines
17 KiB
Swift
441 lines
17 KiB
Swift
//
|
||
// RealNameAuthView.swift
|
||
// suixinkan
|
||
//
|
||
// 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
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
@State private var viewModel = RealNameAuthViewModel()
|
||
@State private var pickedFrontItem: PhotosPickerItem?
|
||
@State private var pickedBackItem: PhotosPickerItem?
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: 14) {
|
||
auditStatusPanel
|
||
identityCard
|
||
privilegeCard
|
||
tipsCard
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 16)
|
||
.padding(.bottom, 34)
|
||
}
|
||
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
|
||
.navigationTitle("实名认证")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.task {
|
||
await loadInfo()
|
||
}
|
||
.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 {
|
||
VStack(spacing: 14) {
|
||
auditRow("审核状态", value: viewModel.info?.auditStatusText?.nonEmpty ?? viewModel.auditStatusText(viewModel.info?.auditStatus ?? 0))
|
||
auditRow("审核人:", value: viewModel.info?.auditor?.name.nonEmpty ?? "--")
|
||
auditRow("审核时间:", value: viewModel.info?.auditAt?.nonEmpty ?? "--")
|
||
auditRow("审核备注:", value: viewModel.info?.rejectReason?.nonEmpty ?? "--")
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 22)
|
||
.background(Color(hex: 0xEAF4FF), in: RoundedRectangle(cornerRadius: 14))
|
||
}
|
||
|
||
/// 构造审核信息行。
|
||
private func auditRow(_ title: String, value: String) -> some View {
|
||
HStack(alignment: .top) {
|
||
Text(title)
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundStyle(Color(hex: 0x1687D9))
|
||
Spacer(minLength: 20)
|
||
Text(value)
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundStyle(Color(hex: 0x1687D9))
|
||
.multilineTextAlignment(.trailing)
|
||
}
|
||
}
|
||
|
||
private var identityCard: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
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
|
||
}
|
||
submitButton
|
||
statusBanner
|
||
}
|
||
.padding(.horizontal, 18)
|
||
.padding(.vertical, 20)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: 14))
|
||
}
|
||
|
||
/// 构造证件图片预览区域。
|
||
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))
|
||
PhotosPicker(selection: pickedItem, matching: .images) {
|
||
imagePreview(side: side, pendingData: pendingData, remoteURL: remoteURL, progress: progress)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(viewModel.submitting || viewModel.info?.verified == true)
|
||
}
|
||
}
|
||
|
||
private var validitySection: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack(spacing: 10) {
|
||
Text("有效期")
|
||
.font(.system(size: 17, weight: .semibold))
|
||
.foregroundStyle(Color(hex: 0x222222))
|
||
Spacer()
|
||
Button {
|
||
viewModel.isLongValid.toggle()
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: viewModel.isLongValid ? "checkmark.circle.fill" : "circle")
|
||
.font(.system(size: 18, weight: .semibold))
|
||
Text("长期有效")
|
||
.font(.system(size: 15))
|
||
}
|
||
.foregroundStyle(viewModel.isLongValid ? AppDesign.primary : Color(hex: 0x8EA0AF))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
HStack(spacing: 12) {
|
||
dateField(selection: startDateBinding)
|
||
dateField(selection: endDateBinding)
|
||
.disabled(viewModel.isLongValid)
|
||
.opacity(viewModel.isLongValid ? 0.45 : 1)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var smsSection: some View {
|
||
HStack(spacing: 10) {
|
||
TextField("", text: smsCodeBinding, prompt: Text("请输入短信验证码").foregroundColor(AppDesign.placeholder))
|
||
.keyboardType(.numberPad)
|
||
.font(.system(size: 16))
|
||
.appInputFieldStyle(cornerRadius: 8, minHeight: 48)
|
||
Button {
|
||
Task { await sendCode() }
|
||
} label: {
|
||
Text(viewModel.sendingCode ? "发送中" : "获取验证码")
|
||
.font(.system(size: 14, weight: .medium))
|
||
.foregroundStyle(viewModel.sendingCode ? Color(hex: 0x9CA3AF) : .white)
|
||
.frame(width: 104, height: 48)
|
||
.background(viewModel.sendingCode ? Color(hex: 0xEEF2F7) : AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(viewModel.sendingCode)
|
||
}
|
||
}
|
||
|
||
private var submitButton: some View {
|
||
Button {
|
||
Task { await submit() }
|
||
} label: {
|
||
Text(viewModel.submitting ? "提交中..." : "下一步")
|
||
.font(.system(size: 18, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 52)
|
||
.background(canSubmit ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 10))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!canSubmit || viewModel.submitting)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var statusBanner: some View {
|
||
if let statusMessage = viewModel.statusMessage {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: statusMessageIsSuccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||
.font(.system(size: 12))
|
||
Text(statusMessage)
|
||
.font(.system(size: 13))
|
||
}
|
||
.foregroundStyle(statusMessageIsSuccess ? AppDesign.success : AppDesign.warning)
|
||
.padding(.horizontal, 10)
|
||
.frame(minHeight: 34, alignment: .leading)
|
||
.background((statusMessageIsSuccess ? AppDesign.success : AppDesign.warning).opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
}
|
||
|
||
private var privilegeCard: some View {
|
||
VStack(alignment: .leading, spacing: 22) {
|
||
Text("实名认证特权")
|
||
.font(.system(size: 20, weight: .semibold))
|
||
.foregroundStyle(Color(hex: 0x222222))
|
||
privilegeRow(icon: "checkmark.circle.fill", title: "开启接单权限", message: "完成实名认证后可立即接单,实名状态对游客可见")
|
||
privilegeRow(icon: "person.3.fill", title: "加入营销群", message: "专业摄影师交流群,获取更多流量与技术支持")
|
||
privilegeRow(icon: "map.fill", title: "景区选择权限", message: "可自主选择运营景区,获得独家运营资源")
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 22)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: 14))
|
||
}
|
||
|
||
/// 构造认证特权行。
|
||
private func privilegeRow(icon: String, title: String, message: String) -> some View {
|
||
HStack(alignment: .top, spacing: 14) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: 18, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 30, height: 30)
|
||
.background(AppDesign.primary, in: Circle())
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(title)
|
||
.font(.system(size: 17, weight: .semibold))
|
||
.foregroundStyle(Color(hex: 0x333333))
|
||
Text(message)
|
||
.font(.system(size: 14))
|
||
.foregroundStyle(Color(hex: 0x777777))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var tipsCard: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
Text("温馨提示:")
|
||
.font(.system(size: 20, weight: .semibold))
|
||
.foregroundStyle(Color(hex: 0x333333))
|
||
tipText("请确保填写的信息真实有效")
|
||
tipText("身份信息仅用于实名认证,我们将严格保护您的隐私")
|
||
tipText("如有疑问请联系客服")
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 22)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: 14))
|
||
}
|
||
|
||
/// 构造温馨提示行。
|
||
private func tipText(_ text: String) -> some View {
|
||
HStack(alignment: .top, spacing: 8) {
|
||
Text("-")
|
||
Text(text)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.font(.system(size: 15))
|
||
.foregroundStyle(Color(hex: 0x666666))
|
||
}
|
||
|
||
/// 构造证件图片预览。
|
||
private func imagePreview(side: RealNameImageSide, pendingData: Data?, remoteURL: String, progress: Int?) -> some View {
|
||
ZStack {
|
||
if let pendingData, let image = UIImage(data: pendingData) {
|
||
Image(uiImage: image)
|
||
.resizable()
|
||
.scaledToFit()
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else {
|
||
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)
|
||
.frame(height: 166)
|
||
.background(Color(hex: 0xFAFAFA))
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(Color(hex: 0xE0E0E0), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
|
||
)
|
||
}
|
||
|
||
/// 构造证件图片空状态内容。
|
||
private func placeholderImageContent(side: RealNameImageSide) -> some View {
|
||
ZStack {
|
||
Color(hex: 0xF3F6FA)
|
||
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))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 构造文本输入行。
|
||
private func formField(_ title: String, text: Binding<String>, placeholder: String) -> some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(title)
|
||
.font(.system(size: 17, weight: .semibold))
|
||
.foregroundStyle(Color(hex: 0x222222))
|
||
TextField("", text: text, prompt: Text(placeholder).foregroundColor(AppDesign.placeholder))
|
||
.font(.system(size: 16))
|
||
.foregroundStyle(Color(hex: 0x333333))
|
||
.appInputFieldStyle(cornerRadius: 8, minHeight: 54)
|
||
}
|
||
}
|
||
|
||
/// 构造日期选择器。
|
||
private func dateField(selection: Binding<Date>) -> some View {
|
||
DatePicker("", selection: selection, displayedComponents: .date)
|
||
.labelsHidden()
|
||
.datePickerStyle(.compact)
|
||
.font(.system(size: 16))
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 54)
|
||
.background(Color(hex: 0xF1F1F1), in: RoundedRectangle(cornerRadius: 6))
|
||
}
|
||
|
||
private var canSubmit: Bool {
|
||
viewModel.validationMessage == nil && !viewModel.submitting
|
||
}
|
||
|
||
private var shouldShowSmsSection: Bool {
|
||
!viewModel.smsCode.isEmpty || viewModel.info?.verified != true
|
||
}
|
||
|
||
private var statusMessageIsSuccess: Bool {
|
||
guard let statusMessage = viewModel.statusMessage else { return false }
|
||
return statusMessage.contains("成功") || statusMessage.contains("已发送") || statusMessage.contains("已提交")
|
||
}
|
||
|
||
private var realNameBinding: Binding<String> {
|
||
Binding(get: { viewModel.realName }, set: { viewModel.realName = $0 })
|
||
}
|
||
|
||
private var idCardNoBinding: Binding<String> {
|
||
Binding(get: { viewModel.idCardNo }, set: { viewModel.idCardNo = $0 })
|
||
}
|
||
|
||
private var smsCodeBinding: Binding<String> {
|
||
Binding(get: { viewModel.smsCode }, set: { viewModel.smsCode = $0 })
|
||
}
|
||
|
||
private var startDateBinding: Binding<Date> {
|
||
Binding(get: { viewModel.startDate }, set: { viewModel.startDate = $0 })
|
||
}
|
||
|
||
private var endDateBinding: Binding<Date> {
|
||
Binding(get: { viewModel.endDate }, set: { viewModel.endDate = $0 })
|
||
}
|
||
|
||
/// 拉取实名认证信息。
|
||
private func loadInfo() async {
|
||
do {
|
||
try await globalLoading.withOptionalLoading(viewModel.info == nil, message: "加载中...") {
|
||
try await viewModel.load(api: profileAPI)
|
||
}
|
||
} catch is CancellationError {
|
||
return
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 发送验证码。
|
||
private func sendCode() async {
|
||
do {
|
||
try await viewModel.sendCode(api: profileAPI)
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 提交实名资料。
|
||
private func submit() async {
|
||
do {
|
||
try await globalLoading.withLoading(message: "提交中...") {
|
||
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 {
|
||
/// 去除空白后返回非空字符串。
|
||
var nonEmpty: String? {
|
||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return text.isEmpty ? nil : text
|
||
}
|
||
}
|