Sync migrated iOS modules
This commit is contained in:
383
suixinkan/Features/Profile/Views/RealNameAuthView.swift
Normal file
383
suixinkan/Features/Profile/Views/RealNameAuthView.swift
Normal file
@ -0,0 +1,383 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user