402 lines
14 KiB
Swift
402 lines
14 KiB
Swift
//
|
||
// ResetPasswordView.swift
|
||
// punchnet
|
||
//
|
||
// Created by 安礼成 on 2026/3/9.
|
||
//
|
||
import SwiftUI
|
||
import Observation
|
||
|
||
// MARK: - 1. 根视图
|
||
struct ResetPasswordRootView: View {
|
||
@State private var resetPasswordModel = ResetPasswordModel()
|
||
@Environment(AppContext.self) private var appContext: AppContext
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
// 背景毛玻璃 (macOS 风格)
|
||
VisualEffectView(material: .underWindowBackground, blendingMode: .behindWindow)
|
||
.ignoresSafeArea()
|
||
|
||
Group {
|
||
switch resetPasswordModel.stage {
|
||
case .requestVerifyCode:
|
||
GetVerifyCodeView()
|
||
case .submitVerifyCode:
|
||
SubmitVerifyCodeView()
|
||
case .resetPassword:
|
||
ResetPasswordView()
|
||
case .success:
|
||
ResetPasswordSuccessView()
|
||
}
|
||
}
|
||
.transition(.asymmetric(
|
||
insertion: .move(edge: resetPasswordModel.transitionEdge).combined(with: .opacity),
|
||
removal: .move(edge: resetPasswordModel.transitionEdge == .trailing ? .leading : .trailing).combined(with: .opacity)
|
||
))
|
||
}
|
||
.environment(resetPasswordModel)
|
||
.overlay(alignment: .topLeading) {
|
||
// 仅在非成功页面显示返回按钮
|
||
if resetPasswordModel.stage != .success {
|
||
Button(action: {
|
||
// 执行返回逻辑,例如重置到登录
|
||
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
|
||
self.appContext.appScene = .login(username: nil)
|
||
}
|
||
}) {
|
||
HStack {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.padding(5)
|
||
|
||
Text("首页")
|
||
.font(.system(size: 16, weight: .regular))
|
||
}
|
||
.contentShape(Rectangle()) // 扩大点击热区
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding([.top, .leading], 16) // 控制距离窗口边缘的边距
|
||
.transition(.opacity) // 按钮出现的动画
|
||
}
|
||
}
|
||
.frame(width: 500, height: 400)
|
||
}
|
||
}
|
||
|
||
// MARK: - 2. 第一步:获取验证码
|
||
struct GetVerifyCodeView: View {
|
||
@Environment(ResetPasswordModel.self) var resetPasswordModel
|
||
@State private var isProcessing = false
|
||
@State private var showAlert = false
|
||
@State private var errorMessage = ""
|
||
|
||
var body: some View {
|
||
@Bindable var model = resetPasswordModel
|
||
|
||
VStack(spacing: 24) {
|
||
headerSection(title: "重置密码", subtitle: "请输入关联的邮箱来验证身份")
|
||
|
||
PunchTextField(icon: "person.crop.circle", placeholder: "邮箱", text: $model.username)
|
||
.frame(width: 280)
|
||
|
||
Button {
|
||
Task { @MainActor in
|
||
await self.sendVerifyCode(username: model.username)
|
||
}
|
||
} label: {
|
||
Text("获取验证码")
|
||
.fontWeight(.medium)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.large)
|
||
.frame(width: 280)
|
||
.disabled(!SDLUtil.isValidIdentifyContact(model.username) || isProcessing)
|
||
|
||
Spacer()
|
||
}
|
||
.padding(40)
|
||
.alert(isPresented: $showAlert) {
|
||
Alert(title: Text("提示"), message: Text(errorMessage))
|
||
}
|
||
}
|
||
|
||
// 发送验证码
|
||
private func sendVerifyCode(username: String) async {
|
||
self.isProcessing = true
|
||
defer {
|
||
self.isProcessing = false
|
||
}
|
||
|
||
do {
|
||
let resetSession = try await resetPasswordModel.requestVerifyCode(username: username)
|
||
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
|
||
self.resetPasswordModel.stage = .submitVerifyCode
|
||
self.resetPasswordModel.sessionId = resetSession.sessionId
|
||
|
||
self.resetPasswordModel.transitionEdge = .trailing
|
||
}
|
||
} catch {
|
||
self.errorMessage = error.localizedDescription
|
||
self.showAlert = true
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - 3. 第二步:验证验证码
|
||
struct SubmitVerifyCodeView: View {
|
||
@Environment(ResetPasswordModel.self) var resetPasswordModel: ResetPasswordModel
|
||
|
||
@State private var code: String = ""
|
||
@State private var isProcessing = false
|
||
|
||
// 重发逻辑
|
||
@State private var remainingSeconds = 60
|
||
@State private var isResendEnabled = false
|
||
|
||
// 错误逻辑处理
|
||
@State private var showAlert = false
|
||
@State private var errorMessage = ""
|
||
|
||
var validInputCode: Bool {
|
||
return !self.code.isEmpty && self.code.count == 6 && self.code.allSatisfy {$0.isNumber}
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 24) {
|
||
headerSection(title: "身份验证", subtitle: "验证码已发送至 \(self.resetPasswordModel.username)")
|
||
|
||
VStack(alignment: .trailing, spacing: 16) {
|
||
PunchTextField(icon: "envelope.badge", placeholder: "输入 6 位验证码", text: $code)
|
||
|
||
Button(isResendEnabled ? "重新获取" : "重新获取 (\(remainingSeconds)s)") {
|
||
Task { @MainActor in
|
||
await self.resendAction(username: self.resetPasswordModel.username)
|
||
}
|
||
}
|
||
.buttonStyle(.link)
|
||
.font(.caption)
|
||
.disabled(!isResendEnabled)
|
||
}
|
||
.frame(width: 280)
|
||
|
||
VStack(spacing: 12) {
|
||
Button {
|
||
Task { @MainActor in
|
||
await self.submitAction(sessionId: self.resetPasswordModel.sessionId)
|
||
}
|
||
} label: {
|
||
Text("验证并继续")
|
||
.fontWeight(.medium)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.large)
|
||
.disabled(!validInputCode)
|
||
|
||
Button("返回上一步") {
|
||
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
|
||
self.resetPasswordModel.stage = .requestVerifyCode
|
||
self.resetPasswordModel.transitionEdge = .leading
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.frame(width: 280)
|
||
|
||
Spacer()
|
||
}
|
||
.padding(40)
|
||
.task {
|
||
await startCountdown()
|
||
}
|
||
.alert(isPresented: $showAlert) {
|
||
Alert(title: Text("提示"), message: Text(self.errorMessage))
|
||
}
|
||
}
|
||
|
||
private func resendAction(username: String) async {
|
||
_ = try? await resetPasswordModel.requestVerifyCode(username: username)
|
||
await startCountdown()
|
||
}
|
||
|
||
private func startCountdown() async {
|
||
self.isResendEnabled = false
|
||
self.remainingSeconds = 60
|
||
while remainingSeconds > 0 {
|
||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||
self.remainingSeconds -= 1
|
||
}
|
||
self.isResendEnabled = true
|
||
}
|
||
|
||
private func submitAction(sessionId: Int) async {
|
||
self.isProcessing = true
|
||
defer {
|
||
self.isProcessing = false
|
||
}
|
||
|
||
do {
|
||
let result = try await resetPasswordModel.submitVerifyCode(sessionId: sessionId, verifyCode: code)
|
||
NSLog("reset password submit verify code result: \(result)")
|
||
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
|
||
self.resetPasswordModel.stage = .resetPassword
|
||
self.resetPasswordModel.transitionEdge = .trailing
|
||
}
|
||
} catch let err as SDLAPIError {
|
||
self.showAlert = true
|
||
self.errorMessage = err.message
|
||
} catch let err {
|
||
self.showAlert = true
|
||
self.errorMessage = err.localizedDescription
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - 4. 第三步:重置密码
|
||
struct ResetPasswordView: View {
|
||
@Environment(ResetPasswordModel.self) var resetPasswordModel: ResetPasswordModel
|
||
|
||
@State private var password = ""
|
||
@State private var confirm = ""
|
||
@State private var isProcessing = false
|
||
|
||
// 错误逻辑处理
|
||
@State private var showAlert = false
|
||
@State private var errorMessage = ""
|
||
|
||
// 判断输入是否合法
|
||
var isInputValid: Bool {
|
||
!password.isEmpty && password == confirm && password.count >= 8
|
||
}
|
||
|
||
// 提示错误信息
|
||
var passwordError: String? {
|
||
if password.isEmpty || confirm.isEmpty {
|
||
return nil
|
||
}
|
||
|
||
if password != confirm {
|
||
return "两次输入的密码不一致"
|
||
}
|
||
|
||
if password.count < 8 {
|
||
return "密码至少需要 8 位"
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 24) {
|
||
headerSection(title: "设置新密码", subtitle: "请为账号 \(self.resetPasswordModel.username) 设置一个强密码")
|
||
|
||
VStack(spacing: 12) {
|
||
PunchTextField(icon: "lock.shield", placeholder: "新密码 (至少8位)", text: $password, isSecure: true)
|
||
PunchTextField(icon: "lock.shield", placeholder: "确认新密码", text: $confirm, isSecure: true)
|
||
|
||
if let passwordError = self.passwordError {
|
||
Text(passwordError)
|
||
.font(.caption)
|
||
.foregroundColor(.red)
|
||
.frame(width: 280, alignment: .leading)
|
||
}
|
||
}
|
||
.frame(width: 280)
|
||
|
||
Button {
|
||
Task { @MainActor in
|
||
await self.handleReset(sessionId: self.resetPasswordModel.sessionId)
|
||
}
|
||
} label: {
|
||
Text("重置密码并登录")
|
||
.fontWeight(.medium)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.large)
|
||
.frame(width: 280)
|
||
.disabled(!isInputValid || isProcessing)
|
||
|
||
Spacer()
|
||
}
|
||
.padding(40)
|
||
.alert(isPresented: $showAlert) {
|
||
Alert(title: Text("提示"), message: Text(self.errorMessage))
|
||
}
|
||
}
|
||
|
||
private func handleReset(sessionId: Int) async {
|
||
self.isProcessing = true
|
||
defer {
|
||
self.isProcessing = false
|
||
}
|
||
|
||
do {
|
||
let result = try await resetPasswordModel.resetPassword(sessionId: sessionId, newPassword: password)
|
||
print("密码重置成功: \(result)")
|
||
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
|
||
self.resetPasswordModel.stage = .success
|
||
self.resetPasswordModel.transitionEdge = .trailing
|
||
}
|
||
} catch {
|
||
self.showAlert = true
|
||
self.errorMessage = "重置失败, 请稍后重试"
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
struct ResetPasswordSuccessView: View {
|
||
@Environment(AppContext.self) var appContext: AppContext
|
||
@Environment(ResetPasswordModel.self) var resetPasswordModel: ResetPasswordModel
|
||
|
||
// 动画状态
|
||
@State private var animateIcon = false
|
||
@State private var animateText = false
|
||
|
||
var body: some View {
|
||
VStack(spacing: 32) {
|
||
Spacer()
|
||
|
||
// 成功图标 (使用蓝紫色调区别于注册的绿色)
|
||
ZStack {
|
||
Circle()
|
||
.fill(Color.blue.opacity(0.1))
|
||
.frame(width: 100, height: 100)
|
||
.scaleEffect(animateIcon ? 1.1 : 0.95)
|
||
.opacity(animateIcon ? 0.8 : 1.0)
|
||
|
||
Image(systemName: "lock.circle.fill")
|
||
.font(.system(size: 56))
|
||
.foregroundStyle(.blue.gradient)
|
||
.scaleEffect(animateIcon ? 1.05 : 1.0)
|
||
}
|
||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||
|
||
VStack(spacing: 32) {
|
||
VStack(spacing: 12) {
|
||
Text("密码重置成功")
|
||
.font(.title2.bold())
|
||
|
||
Text("您的新密码已生效。\n为了安全,建议您立即尝试使用新密码登录。")
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
|
||
Button(action: {
|
||
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
|
||
self.appContext.appScene = .login(username: self.resetPasswordModel.username)
|
||
}
|
||
}) {
|
||
Text("返回登录")
|
||
.fontWeight(.bold)
|
||
.frame(width: 200)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.large)
|
||
.tint(.blue)
|
||
}
|
||
.opacity(animateText ? 1.0 : 0.0)
|
||
.offset(y: animateText ? 0 : 20)
|
||
|
||
Spacer()
|
||
}
|
||
.padding(40)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.onAppear {
|
||
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
|
||
animateIcon = true
|
||
}
|
||
withAnimation(.spring(duration: 0.6, bounce: 0.3).delay(0.4)) {
|
||
animateText = true
|
||
}
|
||
}
|
||
}
|
||
}
|