2026-05-11 12:02:03 +08:00

426 lines
14 KiB
Swift

//
// 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 {
ZStack {
Color.clear
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 {
switch authMethod {
case .account:
LoginAccountView(username: self.username ?? "")
.transition(.move(edge: .leading).combined(with: .opacity))
case .token:
LoginTokenView()
.transition(.move(edge: .trailing).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)
}
}
}
}
// 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(username: 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 tokenHistory: [TokenHistoryItem] = []
@State private var wantsTokenHistory = false
@State private var showTokenHistory = false
@State private var isLoading = false
@FocusState private var isTokenFocused: Bool
//
@State private var showAlert: Bool = false
@State private var errorMessage: String = ""
private var normalizedToken: String {
token.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var visibleTokenHistory: [TokenHistoryItem] {
guard !normalizedToken.isEmpty else {
return tokenHistory
}
let matchedItems = tokenHistory.filter {
$0.token.localizedCaseInsensitiveContains(normalizedToken)
|| ($0.networkName?.localizedCaseInsensitiveContains(normalizedToken) == true)
}
return matchedItems
}
private var tokenHistoryPopoverBinding: Binding<Bool> {
Binding(
get: {
showTokenHistory
},
set: { isPresented in
showTokenHistory = isPresented
if !isPresented {
wantsTokenHistory = false
}
}
)
}
var body: some View {
VStack(spacing: 20) {
tokenInputField
Button(action: {
Task { @MainActor in
await self.login()
}
}) {
Text("验证并连接")
.fontWeight(.medium)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.frame(width: 280)
.disabled(normalizedToken.isEmpty || isLoading)
}
.alert(isPresented: $showAlert) {
Alert(title: Text("提示"), message: Text(self.errorMessage))
}
.onAppear {
self.tokenHistory = self.appContext.loadCacheTokenHistory()
if let cacheToken = self.appContext.loadCacheToken() {
self.token = cacheToken
}
}
}
private var tokenInputField: some View {
HStack {
Image(systemName: "key.fill")
.foregroundColor(.secondary)
.frame(width: 20)
TextField("请输入认证密钥 (Token)", text: $token)
.textFieldStyle(.plain)
.focused($isTokenFocused)
}
.padding(8)
.frame(width: 280)
.background(Color(NSColor.controlBackgroundColor).opacity(0.5))
.cornerRadius(6)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2), lineWidth: 1))
.onTapGesture {
self.presentTokenHistory()
}
.onChange(of: isTokenFocused) {
if isTokenFocused {
self.presentTokenHistory()
} else if !showTokenHistory {
self.wantsTokenHistory = false
}
}
.onChange(of: token) {
if wantsTokenHistory {
self.refreshTokenHistoryPopover()
}
}
.popover(isPresented: tokenHistoryPopoverBinding, arrowEdge: .bottom) {
tokenHistoryPopover
}
}
private var tokenHistoryPopover: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(visibleTokenHistory, id: \.token) { item in
Button(action: {
self.token = item.token
self.dismissTokenHistory()
}) {
HStack(spacing: 8) {
Image(systemName: item.token == normalizedToken ? "checkmark" : "key.fill")
.foregroundColor(.secondary)
.frame(width: 14)
Text(tokenHistoryTitle(item))
.font(.system(size: 12))
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
}
}
.frame(width: 280)
.padding(.bottom, 6)
}
private func presentTokenHistory() {
self.wantsTokenHistory = true
self.refreshTokenHistoryPopover()
}
private func dismissTokenHistory() {
self.wantsTokenHistory = false
self.showTokenHistory = false
}
private func refreshTokenHistoryPopover() {
self.tokenHistory = self.appContext.loadCacheTokenHistory()
self.showTokenHistory = wantsTokenHistory && !visibleTokenHistory.isEmpty
}
private func tokenHistoryTitle(_ item: TokenHistoryItem) -> String {
guard let networkName = item.networkName, !networkName.isEmpty else {
return item.token
}
return "\(item.token) (\(networkName))"
}
private func login() async {
let finalToken = self.normalizedToken
guard !finalToken.isEmpty else {
return
}
self.isLoading = true
self.dismissTokenHistory()
defer {
self.isLoading = false
}
do {
_ = try await appContext.loginWith(token: finalToken)
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))
}
}
#Preview {
LoginView()
.environment(AppContext())
}