393 lines
13 KiB
Swift
393 lines
13 KiB
Swift
//
|
||
// LoginView.swift
|
||
// punchnet
|
||
//
|
||
// Created by 安礼成 on 2026/1/15.
|
||
//
|
||
|
||
import SwiftUI
|
||
import Observation
|
||
|
||
enum ContactType {
|
||
case phone
|
||
case email
|
||
case invalid
|
||
}
|
||
|
||
enum AuthScreen {
|
||
case login
|
||
case resetPassword
|
||
case register
|
||
}
|
||
|
||
struct LoginRootView: View {
|
||
@Environment(AppContext.self) var appContext: AppContext
|
||
@State private var authScreen = AuthScreen.login
|
||
|
||
var body: some View {
|
||
Group {
|
||
switch self.authScreen {
|
||
case .login:
|
||
LoginView(authScreen: $authScreen)
|
||
case .resetPassword:
|
||
ResetPasswordView()
|
||
case .register:
|
||
ResetPasswordView()
|
||
}
|
||
}
|
||
.onChange(of: self.authScreen) {
|
||
print("app auth screen changed")
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// 登陆页面
|
||
struct LoginView: View {
|
||
@Environment(UserContext.self) var userContext: UserContext
|
||
@State private var authMethod: AuthMethod = .account
|
||
@Binding var authScreen: AuthScreen
|
||
|
||
enum AuthMethod {
|
||
case token
|
||
case account
|
||
}
|
||
|
||
var body: some View {
|
||
VStack {
|
||
Text("PunchNet")
|
||
.font(.system(size: 16, weight: .medium))
|
||
|
||
HStack(alignment: .center, spacing: 30) {
|
||
Button {
|
||
self.authMethod = .token
|
||
} label: {
|
||
HStack {
|
||
Image("logo")
|
||
.resizable()
|
||
.clipped()
|
||
.frame(width: 25, height: 25)
|
||
|
||
Text("密钥登陆")
|
||
.foregroundColor(self.authMethod == .token ? .blue : .black)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Button {
|
||
self.authMethod = .account
|
||
} label: {
|
||
HStack {
|
||
Image("logo")
|
||
.resizable()
|
||
.clipped()
|
||
.frame(width: 25, height: 25)
|
||
Text("账户登陆")
|
||
.foregroundColor(self.authMethod == .account ? .blue : .black)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
Group {
|
||
switch self.authMethod {
|
||
case .token:
|
||
LoginTokenView()
|
||
case .account:
|
||
LoginAccountView(authScreen: $authScreen)
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
.frame(width: 400, height: 400)
|
||
}
|
||
|
||
}
|
||
|
||
extension LoginView {
|
||
|
||
struct LoginTokenView: View {
|
||
@Environment(UserContext.self) var userContext: UserContext
|
||
@State private var token: String = ""
|
||
|
||
@State private var showAlert = false
|
||
@State private var errorMessage = ""
|
||
|
||
var body: some View {
|
||
VStack {
|
||
TextField("认证密钥", text: $token)
|
||
.multilineTextAlignment(.leading)
|
||
.textFieldStyle(PlainTextFieldStyle())
|
||
.frame(width: 200, height: 25)
|
||
.background(Color.clear)
|
||
.foregroundColor(Color.black)
|
||
.overlay(
|
||
Rectangle()
|
||
.frame(height: 1)
|
||
.foregroundColor(.blue)
|
||
.padding(.top, 25)
|
||
, alignment: .topLeading)
|
||
|
||
Rectangle()
|
||
.overlay {
|
||
Text("登陆")
|
||
.font(.system(size: 14, weight: .regular))
|
||
.foregroundColor(.black)
|
||
}
|
||
.frame(width: 120, height: 35)
|
||
.foregroundColor(Color(red: 74 / 255, green: 207 / 255, blue: 154 / 255))
|
||
.cornerRadius(5.0)
|
||
.onTapGesture {
|
||
Task {
|
||
await self.doLogin()
|
||
}
|
||
}
|
||
}
|
||
.onAppear {
|
||
if let cacheToken = self.userContext.loadCacheToken() {
|
||
self.token = cacheToken
|
||
}
|
||
}
|
||
}
|
||
|
||
// 执行登陆操作
|
||
private func doLogin() async {
|
||
do {
|
||
try await self.userContext.loginWithToken(token: self.token)
|
||
print(self.userContext.networkSession?.accessToken)
|
||
|
||
// 保存信息到KeychainStore
|
||
// let store = KeychainStore.shared
|
||
// if let tokenData = loginResult.accessToken.data(using: .utf8) {
|
||
// try store.save(tokenData, account: self.username)
|
||
// }
|
||
|
||
} catch let err as SDLAPIError {
|
||
await MainActor.run {
|
||
self.showAlert = true
|
||
self.errorMessage = err.message
|
||
}
|
||
} catch {
|
||
await MainActor.run {
|
||
self.showAlert = true
|
||
self.errorMessage = "内部错误,请稍后重试"
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
struct LoginAccountView: View {
|
||
@Environment(UserContext.self) var userContext: UserContext
|
||
@Environment(AppContext.self) var appContext: AppContext
|
||
@Binding var authScreen: AuthScreen
|
||
|
||
@State private var username: String = ""
|
||
@State private var password: String = ""
|
||
|
||
@State private var showAlert = false
|
||
@State private var errorMessage = ""
|
||
|
||
struct LoginResult: Decodable {
|
||
var accessToken: String
|
||
var networkId: Int
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case accessToken = "access_token"
|
||
case networkId = "network_id"
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .center) {
|
||
VStack(alignment: .leading) {
|
||
TextField("手机号/邮箱", text: $username)
|
||
.multilineTextAlignment(.leading)
|
||
.textFieldStyle(PlainTextFieldStyle())
|
||
.frame(width: 200, height: 25)
|
||
.background(Color.clear)
|
||
.foregroundColor(Color.black)
|
||
.overlay(
|
||
Rectangle()
|
||
.frame(height: 1)
|
||
.foregroundColor(.blue)
|
||
.padding(.top, 25)
|
||
, alignment: .topLeading)
|
||
|
||
SecureField("密码", text: $password)
|
||
.multilineTextAlignment(.leading)
|
||
.textFieldStyle(PlainTextFieldStyle())
|
||
.frame(width: 200, height: 25)
|
||
.background(Color.clear)
|
||
.foregroundColor(Color.black)
|
||
.overlay(
|
||
Rectangle()
|
||
.frame(height: 1)
|
||
.foregroundColor(.blue)
|
||
.padding(.top, 25)
|
||
, alignment: .topLeading)
|
||
|
||
Button {
|
||
self.authScreen = .resetPassword
|
||
} label: {
|
||
Text("忘记密码?")
|
||
.underline(true, color: .blue)
|
||
.font(.system(size: 14, weight: .regular))
|
||
.foregroundColor(.blue)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
Button {
|
||
if self.username.isEmpty {
|
||
self.showAlert = true
|
||
self.errorMessage = "账号不能为空"
|
||
} else if self.password.isEmpty {
|
||
self.showAlert = true
|
||
self.errorMessage = "密码不能为空"
|
||
} else {
|
||
Task {
|
||
await self.doLogin()
|
||
}
|
||
}
|
||
} label: {
|
||
Text("登陆")
|
||
.font(.system(size: 14, weight: .regular))
|
||
.foregroundColor(.black)
|
||
.frame(width: 120, height: 35)
|
||
}
|
||
.frame(width: 120, height: 35)
|
||
.background(Color(red: 74 / 255, green: 207 / 255, blue: 154 / 255))
|
||
.cornerRadius(5.0)
|
||
}
|
||
.alert(isPresented: $showAlert) {
|
||
Alert(title: Text("错误提示"), message: Text("账号密码为空"))
|
||
}
|
||
.onAppear {
|
||
if let (cacheUsername, cachePassword) = self.userContext.loadCacheUsernameAndPassword() {
|
||
self.username = cacheUsername
|
||
self.password = cachePassword
|
||
}
|
||
}
|
||
}
|
||
|
||
// 执行登陆操作
|
||
private func doLogin() async {
|
||
do {
|
||
try await self.userContext.loginWithAccountAndPassword(username: self.username, password: self.password)
|
||
|
||
// 保存信息到KeychainStore
|
||
// let store = KeychainStore.shared
|
||
// if let tokenData = loginResult.accessToken.data(using: .utf8) {
|
||
// try store.save(tokenData, account: self.username)
|
||
// }
|
||
|
||
} catch let err as SDLAPIError {
|
||
await MainActor.run {
|
||
self.showAlert = true
|
||
self.errorMessage = err.message
|
||
}
|
||
} catch {
|
||
await MainActor.run {
|
||
self.showAlert = true
|
||
self.errorMessage = "内部错误,请稍后重试"
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
// 忘记密码
|
||
struct ResetPasswordView: View {
|
||
@Environment(UserContext.self) var userContext: UserContext
|
||
|
||
@State private var username: String = ""
|
||
|
||
@State private var showAlert = false
|
||
@State private var errorMessage = ""
|
||
|
||
var body: some View {
|
||
VStack {
|
||
Text("重置密码")
|
||
|
||
TextField("手机号/邮箱", text: $username)
|
||
.multilineTextAlignment(.leading)
|
||
.textFieldStyle(PlainTextFieldStyle())
|
||
.frame(width: 200, height: 25)
|
||
.background(Color.clear)
|
||
.foregroundColor(Color.black)
|
||
.overlay(
|
||
Rectangle()
|
||
.frame(height: 1)
|
||
.foregroundColor(.blue)
|
||
.padding(.top, 25)
|
||
, alignment: .topLeading)
|
||
|
||
|
||
Button {
|
||
Task { @MainActor in
|
||
await self.sendVerifyCode()
|
||
}
|
||
} label: {
|
||
Text("获取验证码")
|
||
.font(.system(size: 14, weight: .regular))
|
||
.foregroundColor(.black)
|
||
.frame(width: 200, height: 35)
|
||
}
|
||
.frame(width: 200, height: 35)
|
||
.background(Color(red: 74 / 255, green: 207 / 255, blue: 154 / 255))
|
||
.cornerRadius(5.0)
|
||
}
|
||
.alert(isPresented: $showAlert) {
|
||
Alert(title: Text("提示"), message: Text(self.errorMessage))
|
||
}
|
||
}
|
||
|
||
private func sendVerifyCode() async {
|
||
if username.isEmpty {
|
||
self.showAlert = true
|
||
self.errorMessage = "手机号/邮箱为空"
|
||
return
|
||
}
|
||
|
||
switch identifyContact(username) {
|
||
case .email, .phone:
|
||
do {
|
||
let result = try await self.userContext.sendVerifyCode(username: username)
|
||
print("send verify code result: \(result)")
|
||
} catch let err {
|
||
self.showAlert = true
|
||
self.errorMessage = err.localizedDescription
|
||
}
|
||
case .invalid:
|
||
self.showAlert = true
|
||
self.errorMessage = "手机号/邮箱格式错误"
|
||
}
|
||
}
|
||
|
||
private func identifyContact(_ input: String) -> ContactType {
|
||
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
// 手机号正则(中国手机号为例,以 1 开头,11 位数字)
|
||
let phoneRegex = /^1[3-9][0-9]{9}$/
|
||
// 邮箱正则
|
||
let emailRegex = /^[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$/
|
||
if trimmed.wholeMatch(of: phoneRegex) != nil {
|
||
return .phone
|
||
} else if trimmed.wholeMatch(of: emailRegex) != nil {
|
||
return .email
|
||
} else {
|
||
return .invalid
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
#Preview {
|
||
// ResetPasswordView()
|
||
// .environment(UserContext())
|
||
}
|