384 lines
15 KiB
Swift
384 lines
15 KiB
Swift
//
|
||
// RealNameAuthView.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/22.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
/// 实名认证页面,展示审核状态并允许提交基础实名资料。
|
||
struct RealNameAuthView: View {
|
||
@Environment(ProfileAPI.self) private var profileAPI
|
||
@Environment(ToastCenter.self) private var toastCenter
|
||
@State private var viewModel = RealNameAuthViewModel()
|
||
|
||
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))
|
||
}
|
||
}
|
||
}
|
||
|
||
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: "身份证国徽面", url: viewModel.backUrl)
|
||
identityImageSection(title: "身份证人像面", url: viewModel.frontUrl)
|
||
formField("姓名", text: realNameBinding, placeholder: "请输入姓名")
|
||
formField("身份证号码", text: idCardNoBinding, placeholder: "请输入身份证号码")
|
||
validitySection
|
||
if shouldShowSmsSection {
|
||
smsSection
|
||
}
|
||
urlField("身份证人像面图片 URL", text: frontURLBinding)
|
||
urlField("身份证国徽面图片 URL", text: backURLBinding)
|
||
submitButton
|
||
statusBanner
|
||
}
|
||
.padding(.horizontal, 18)
|
||
.padding(.vertical, 20)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: 14))
|
||
}
|
||
|
||
/// 构造证件图片预览区域。
|
||
private func identityImageSection(title: String, url: String) -> some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text(title)
|
||
.font(.system(size: 17, weight: .semibold))
|
||
.foregroundStyle(Color(hex: 0x222222))
|
||
imagePreview(url: url)
|
||
}
|
||
}
|
||
|
||
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(url: String) -> 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
|
||
}
|
||
}
|
||
} else {
|
||
placeholderImageContent
|
||
}
|
||
}
|
||
.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 var placeholderImageContent: some View {
|
||
ZStack {
|
||
Color(hex: 0xF3F6FA)
|
||
Image(systemName: "photo")
|
||
.font(.system(size: 26, weight: .semibold))
|
||
.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)
|
||
}
|
||
}
|
||
|
||
/// 构造 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)
|
||
.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 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 })
|
||
}
|
||
|
||
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)
|
||
} catch {
|
||
viewModel.statusMessage = error.localizedDescription
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
}
|
||
|
||
private extension String {
|
||
/// 去除空白后返回非空字符串。
|
||
var nonEmpty: String? {
|
||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return text.isEmpty ? nil : text
|
||
}
|
||
}
|