// // RealNameAuthView.swift // suixinkan // // Created by Codex on 2026/6/22. // import PhotosUI import SwiftUI import UIKit /// 实名认证页面,展示审核状态并允许提交基础实名资料。 struct RealNameAuthView: View { @EnvironmentObject private var accountContext: AccountContext @Environment(\.profileAPI) private var profileAPI @Environment(\.ossUploadService) private var ossUploadService @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading @StateObject 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, 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, 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) -> 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 { Binding(get: { viewModel.realName }, set: { viewModel.realName = $0 }) } private var idCardNoBinding: Binding { Binding(get: { viewModel.idCardNo }, set: { viewModel.idCardNo = $0 }) } private var smsCodeBinding: Binding { Binding(get: { viewModel.smsCode }, set: { viewModel.smsCode = $0 }) } private var startDateBinding: Binding { Binding(get: { viewModel.startDate }, set: { viewModel.startDate = $0 }) } private var endDateBinding: Binding { 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 } }