256 lines
7.9 KiB
Swift
256 lines
7.9 KiB
Swift
//
|
||
// LoginView.swift
|
||
// punchnet
|
||
//
|
||
// Created by 安礼成 on 2026/1/15.
|
||
//
|
||
import SwiftUI
|
||
import Observation
|
||
|
||
// MARK: - 主容器视图
|
||
struct LoginView: View {
|
||
@Environment(UserContext.self) var userContext: UserContext
|
||
@State private var authMethod: AuthMethod = .account
|
||
|
||
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()
|
||
.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(UserContext.self) var userContext: UserContext
|
||
@Environment(\.openWindow) private var openWindow
|
||
|
||
@State private var username = ""
|
||
@State private var password = ""
|
||
@State private var isLoading = false
|
||
|
||
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")
|
||
|
||
Button("忘记密码?") {
|
||
openWindow(id: "resetPassword")
|
||
}
|
||
.buttonStyle(.link)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
.frame(width: 280)
|
||
|
||
// 蓝色主按钮
|
||
Button(action: { 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)
|
||
}
|
||
.onAppear {
|
||
if let (cacheUsername, cachePassword) = self.userContext.loadCacheUsernameAndPassword() {
|
||
self.username = cacheUsername
|
||
self.password = cachePassword
|
||
}
|
||
}
|
||
}
|
||
|
||
private func login() {
|
||
isLoading = true
|
||
Task {
|
||
try? await userContext.loginWithAccountAndPassword(username: username, password: password)
|
||
isLoading = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 密钥登录组件
|
||
struct LoginTokenView: View {
|
||
@Environment(UserContext.self) var userContext: UserContext
|
||
@State private var token = ""
|
||
@State private var isLoading = false
|
||
|
||
var body: some View {
|
||
VStack(spacing: 20) {
|
||
CustomTextField(title: "请输入认证密钥 (Token)", text: $token, icon: "key.fill")
|
||
.frame(width: 280)
|
||
|
||
Button(action: {
|
||
self.login()
|
||
}) {
|
||
Text("验证并连接")
|
||
.fontWeight(.medium)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.large)
|
||
.frame(width: 280)
|
||
.disabled(token.isEmpty || isLoading)
|
||
}
|
||
.onAppear {
|
||
if let cacheToken = self.userContext.loadCacheToken() {
|
||
self.token = cacheToken
|
||
}
|
||
}
|
||
}
|
||
|
||
private func login() {
|
||
isLoading = true
|
||
Task {
|
||
try? await userContext.loginWithToken(token: token)
|
||
isLoading = false
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// 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(UserContext())
|
||
}
|