同步已迁移的 iOS 模块

This commit is contained in:
2026-06-22 15:35:12 +08:00
parent 0a0d4fbd79
commit 08465fffde
48 changed files with 4455 additions and 15 deletions

View 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
}
}