// // 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, 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) -> some View { formField(title, text: text, placeholder: "OSS 上传接入前可先填写图片 URL") } /// 构造日期选择器。 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 frontURLBinding: Binding { Binding(get: { viewModel.frontUrl }, set: { viewModel.frontUrl = $0 }) } private var backURLBinding: Binding { Binding(get: { viewModel.backUrl }, set: { viewModel.backUrl = $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 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 } }