Files
suixinkan_ios_new/suixinkan/Features/Profile/Views/RealNameAuthView.swift

444 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
@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()
}
.overlay {
if viewModel.loading && viewModel.info == nil {
ProgressView()
.controlSize(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.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 {
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 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 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
}
}