2026-03-24 00:05:17 +08:00

314 lines
9.9 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.

//
// LoginView.swift
// punchnet
//
// Created by on 2026/1/15.
//
import SwiftUI
import Observation
// MARK: -
struct LoginView: View {
@State private var authMethod: AuthMethod = .account
var username: String?
enum AuthMethod: String, CaseIterable {
case account = "账户登录"
case token = "密钥认证"
}
var body: some View {
VStack(spacing: 0) {
// Logo
VStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.accentColor.opacity(0.1))
.frame(width: 80, height: 80)
Image(systemName: "network") // 使 SF Symbol
.font(.system(size: 38, weight: .semibold))
.foregroundColor(.accentColor)
}
Text("PunchNet")
.font(.system(size: 24, weight: .bold, design: .rounded))
.tracking(1)
}
.padding(.top, 40)
.padding(.bottom, 30)
//
Picker("", selection: $authMethod) {
ForEach(AuthMethod.allCases, id: \.self) { method in
Text(method.rawValue).tag(method)
}
}
.pickerStyle(.segmented)
.frame(width: 220)
.padding(.bottom, 30)
//
ZStack {
if authMethod == .token {
LoginTokenView()
.transition(.move(edge: .trailing).combined(with: .opacity))
} else {
LoginAccountView(username: self.username ?? "")
.transition(.move(edge: .leading).combined(with: .opacity))
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: authMethod)
.frame(height: 180)
Spacer()
//
HStack(spacing: 4) {
Circle()
.fill(Color.green)
.frame(width: 8, height: 8)
Text("服务状态正常")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
.padding(.bottom, 20)
}
.frame(width: 380, height: 520)
// macOS
.background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
.ignoresSafeArea()
}
}
// MARK: -
struct LoginAccountView: View {
@Environment(AppContext.self) var appContext: AppContext
@State var username: String = ""
@State private var password: String = ""
@State private var isLoading = false
//
@State private var showAlert: Bool = false
@State private var errorMessage: String = ""
var body: some View {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 12) {
//
CustomTextField(title: "手机号/邮箱", text: $username, icon: "person.fill")
VStack(alignment: .trailing, spacing: 4) {
CustomSecureField(title: "密码", text: $password, icon: "lock.fill")
HStack {
Button("注册") {
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
self.appContext.appScene = .register
}
}
.buttonStyle(.link)
.font(.system(size: 11))
.foregroundColor(.secondary)
Button("忘记密码?") {
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
self.appContext.appScene = .resetPassword
}
}
.buttonStyle(.link)
.font(.system(size: 11))
.foregroundColor(.secondary)
}
}
}
.frame(width: 280)
//
Button(action: {
Task { @MainActor in
await self.login()
}
}) {
HStack {
if isLoading {
ProgressView()
.controlSize(.small)
.padding(.trailing, 4)
}
Text("登录网络")
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.frame(width: 280)
.keyboardShortcut(.defaultAction) //
.disabled(username.isEmpty || password.isEmpty || isLoading)
}
.alert(isPresented: $showAlert) {
Alert(title: Text("提示"), message: Text(self.errorMessage))
}
.onAppear {
if let (cacheUsername, cachePassword) = self.appContext.loadCacheUsernameAndPassword() {
self.username = cacheUsername
self.password = cachePassword
}
}
}
private func login() async {
self.isLoading = true
defer {
self.isLoading = false
}
do {
_ = try await appContext.loginWith(credit: .accountAndPasword(account: username, password: password))
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
self.appContext.appScene = .logined
}
} catch let err as SDLAPIError {
self.showAlert = true
self.errorMessage = err.message
} catch let err {
self.showAlert = true
self.errorMessage = err.localizedDescription
}
}
}
// MARK: -
struct LoginTokenView: View {
@Environment(AppContext.self) var appContext: AppContext
@State private var token = ""
@State private var isLoading = false
//
@State private var showAlert: Bool = false
@State private var errorMessage: String = ""
var body: some View {
VStack(spacing: 20) {
CustomTextField(title: "请输入认证密钥 (Token)", text: $token, icon: "key.fill")
.frame(width: 280)
Button(action: {
Task { @MainActor in
await self.login()
}
}) {
Text("验证并连接")
.fontWeight(.medium)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.frame(width: 280)
.disabled(token.isEmpty || isLoading)
}
.alert(isPresented: $showAlert) {
Alert(title: Text("提示"), message: Text(self.errorMessage))
}
.onAppear {
if let cacheToken = self.appContext.loadCacheToken() {
self.token = cacheToken
}
}
}
private func login() async {
self.isLoading = true
defer {
self.isLoading = false
}
do {
_ = try await appContext.loginWith(credit: .token(token: token))
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
self.appContext.appScene = .logined
}
} catch let err as SDLAPIError {
self.showAlert = true
self.errorMessage = err.message
} catch let err {
self.showAlert = true
self.errorMessage = err.localizedDescription
}
}
}
// MARK: - UI
struct CustomTextField: View {
let title: String
@Binding var text: String
let icon: String
var body: some View {
HStack {
Image(systemName: icon)
.foregroundColor(.secondary)
.frame(width: 20)
TextField(title, text: $text)
.textFieldStyle(.plain)
}
.padding(8)
.background(Color(NSColor.controlBackgroundColor).opacity(0.5))
.cornerRadius(6)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2), lineWidth: 1))
}
}
struct CustomSecureField: View {
let title: String
@Binding var text: String
let icon: String
var body: some View {
HStack {
Image(systemName: icon)
.foregroundColor(.secondary)
.frame(width: 20)
SecureField(title, text: $text)
.textFieldStyle(.plain)
}
.padding(8)
.background(Color(NSColor.controlBackgroundColor).opacity(0.5))
.cornerRadius(6)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2), lineWidth: 1))
}
}
// MARK: - 1. UI ( Material )
struct VisualEffectView: NSViewRepresentable {
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.material = material
view.blendingMode = blendingMode
view.state = .active
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
nsView.material = material
nsView.blendingMode = blendingMode
}
}
#Preview {
LoginView()
.environment(AppContext(noticePort: 0))
}