punchnet-macos/punchnet/Views/ResetPassword/ResetPasswordView.swift
2026-03-23 23:29:29 +08:00

402 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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