450 lines
14 KiB
Swift
450 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
|
|
HStack(spacing: 4) {
|
|
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(.leading, 10)
|
|
.padding(.vertical, 6)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Button(action: {
|
|
self.deleteTokenHistoryItem(item)
|
|
}) {
|
|
Image(systemName: "trash")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
.frame(width: 24, height: 24)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.trailing, 6)
|
|
}
|
|
}
|
|
.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 deleteTokenHistoryItem(_ item: TokenHistoryItem) {
|
|
do {
|
|
try self.appContext.deleteCacheToken(item.token)
|
|
self.tokenHistory = self.appContext.loadCacheTokenHistory()
|
|
self.showTokenHistory = wantsTokenHistory && !visibleTokenHistory.isEmpty
|
|
} catch {
|
|
self.showAlert = true
|
|
self.errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|