import SwiftUI // MARK: - Auth Manager (shared state) @MainActor final class AuthManager: ObservableObject { @Published var token: String @Published var nickname: String @Published var userID: String @Published var showingRegister = false @Published var showingSSO = false @Published var showingPhoneBinding = false @Published var ssoPendingToken: String? @Published var ssoPendingUserId = "" init() { let defaults = UserDefaults.standard token = defaults.string(forKey: "oral_trainer_token") ?? "" nickname = defaults.string(forKey: "oral_trainer_nickname") ?? "" let userID = defaults.string(forKey: "oral_trainer_user_id") ?? "" AppConfig.currentUserId = userID self.userID = userID AppConfig.authToken = token } var isLoggedIn: Bool { !token.isEmpty } func saveSession(_ session: AuthSession) { token = session.token nickname = session.user.nickname AppConfig.authToken = session.token userID = session.user.id AppConfig.currentUserId = session.user.id let defaults = UserDefaults.standard defaults.set(session.token, forKey: "oral_trainer_token") defaults.set(session.user.nickname, forKey: "oral_trainer_nickname") defaults.set(session.user.id, forKey: "oral_trainer_user_id") } func logout() { token = "" nickname = "" AppConfig.authToken = "" userID = "" AppConfig.currentUserId = "" showingRegister = false showingSSO = false showingPhoneBinding = false ssoPendingToken = nil ssoPendingUserId = "" let defaults = UserDefaults.standard defaults.removeObject(forKey: "oral_trainer_token") defaults.removeObject(forKey: "oral_trainer_nickname") defaults.removeObject(forKey: "oral_trainer_user_id") } } // MARK: - Auth View (root auth screen) struct AuthView: View { @ObservedObject var auth: AuthManager @State private var username = "" @State private var password = "" @State private var statusText = "" @State private var isLoading = false var onLoginSuccess: () -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { Spacer() Text("八哥口语") .font(.system(size: 32, weight: .bold)) .foregroundColor(.white) Text("请先注册或登录后开始学习") .font(.system(size: 15)) .foregroundColor(Theme.textMuted) .padding(.top, 6) .padding(.bottom, 24) TextField("用户名", text: $username) .textFieldStyle(.plain) .autocorrectionDisabled() .textInputAutocapitalization(.never) .padding(12) .background(Theme.surface) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) SecureField("密码(至少 8 位)", text: $password) .textFieldStyle(.plain) .padding(12) .padding(.top, 8) .background(Theme.surface) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) Button { Task { await login() } } label: { Text(isLoading ? "登录中..." : "登录") .frame(maxWidth: .infinity) .frame(height: 44) .background(Theme.accent) .foregroundColor(.white) .clipShape(RoundedRectangle(cornerRadius: 8)) } .padding(.top, 20) .disabled(isLoading) Button { auth.showingRegister = true } label: { Text("注册") .frame(maxWidth: .infinity) .frame(height: 44) .background(Theme.surface) .foregroundColor(.white) .clipShape(RoundedRectangle(cornerRadius: 8)) } .padding(.top, 8) Text("── 或 ──") .font(.system(size: 13)) .foregroundColor(Theme.textMuted) .frame(maxWidth: .infinity) .padding(.top, 12) Button { auth.showingSSO = true } label: { Text("第一课堂一键登录") .frame(maxWidth: .infinity) .frame(height: 44) .background(Theme.buttonBlue) .foregroundColor(.white) .clipShape(RoundedRectangle(cornerRadius: 8)) } .padding(.top, 8) if !statusText.isEmpty { Text(statusText) .font(.system(size: 13)) .foregroundColor(Theme.accentLight) .padding(.top, 14) } Spacer() } .padding(.horizontal, 28) .background(Theme.background.ignoresSafeArea()) .fullScreenCover(isPresented: $auth.showingRegister) { RegisterView(auth: auth) { onLoginSuccess() } } .fullScreenCover(isPresented: $auth.showingSSO) { SSOWebView(auth: auth) { onLoginSuccess() } } .fullScreenCover(isPresented: $auth.showingPhoneBinding) { PhoneBindingView(auth: auth) { onLoginSuccess() } } } private func login() async { guard !username.isEmpty, !password.isEmpty else { statusText = "请填写用户名和密码" return } isLoading = true statusText = "登录中..." defer { isLoading = false } do { let session = try await AuthAPI.login(username: username, password: password) auth.saveSession(session) onLoginSuccess() } catch { statusText = "失败:\(error.localizedDescription)" } } } // MARK: - Register View struct RegisterView: View { @ObservedObject var auth: AuthManager @Environment(\.dismiss) private var dismiss @State private var username = "" @State private var password = "" @State private var nickname = "" @State private var phone = "" @State private var statusText = "" @State private var isLoading = false var onLoginSuccess: () -> Void var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { Text("注册账号") .font(.system(size: 28, weight: .bold)) .foregroundColor(.white) Text("填写以下信息完成注册") .font(.system(size: 14)) .foregroundColor(Theme.textMuted) .padding(.top, 6) .padding(.bottom, 24) fieldLabel("用户名") TextField("3-50 位字母、数字或下划线", text: $username) .textFieldStyle(.plain) .autocorrectionDisabled() .textInputAutocapitalization(.never) .padding(12) .background(Theme.surface) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) fieldLabel("密码") SecureField("至少 8 位", text: $password) .textFieldStyle(.plain) .padding(12) .background(Theme.surface) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) fieldLabel("姓名") TextField("你的真实姓名或昵称", text: $nickname) .textFieldStyle(.plain) .padding(12) .background(Theme.surface) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) fieldLabel("手机号") TextField("请输入 11 位手机号", text: $phone) .textFieldStyle(.plain) .keyboardType(.phonePad) .padding(12) .background(Theme.surface) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) Button { Task { await register() } } label: { Text(isLoading ? "注册中..." : "注册") .frame(maxWidth: .infinity) .frame(height: 48) .background(Theme.accent) .foregroundColor(.white) .clipShape(RoundedRectangle(cornerRadius: 8)) } .padding(.top, 20) .disabled(isLoading) Button { dismiss() } label: { Text("返回登录") .frame(maxWidth: .infinity) .frame(height: 44) .background(Theme.surface) .foregroundColor(.white) .clipShape(RoundedRectangle(cornerRadius: 8)) } .padding(.top, 8) if !statusText.isEmpty { Text(statusText) .font(.system(size: 13)) .foregroundColor(Theme.accentLight) .padding(.top, 14) } } .padding(.horizontal, 28) .padding(.top, 60) } .background(Theme.background.ignoresSafeArea()) } private func fieldLabel(_ text: String) -> some View { Text(text) .font(.system(size: 13)) .foregroundColor(Theme.textMuted) .padding(.top, 12) .padding(.bottom, 4) } private func register() async { guard username.count >= 3 else { statusText = "用户名至少 3 个字符"; return } guard password.count >= 8 else { statusText = "密码至少 8 位"; return } guard !nickname.isEmpty else { statusText = "请填写姓名"; return } let cleanedPhone = phone.trimmingCharacters(in: .whitespaces) guard cleanedPhone.range(of: "^1[3-9]\\d{9}$", options: .regularExpression) != nil else { statusText = "请输入有效的 11 位手机号" return } isLoading = true statusText = "注册中..." defer { isLoading = false } do { let session = try await AuthAPI.register( username: username, password: password, nickname: nickname, phone: cleanedPhone ) auth.saveSession(session) dismiss() onLoginSuccess() } catch { statusText = "注册失败:\(error.localizedDescription)" } } } // MARK: - SSO WebView import WebKit struct SSOWebView: View { @ObservedObject var auth: AuthManager @Environment(\.dismiss) private var dismiss var onLoginSuccess: () -> Void var body: some View { VStack(spacing: 0) { Button { dismiss() } label: { Text("取消登录") .frame(maxWidth: .infinity) .frame(height: 44) .background(Theme.surface) .foregroundColor(.white) } SSOWebViewRepresentable() Button { readJwtAndLogin() } label: { Text("已登录,点击继续") .frame(maxWidth: .infinity) .frame(height: 48) .background(Theme.accent) .foregroundColor(.white) .clipShape(RoundedRectangle(cornerRadius: 8)) } .padding(8) } .background(Theme.background.ignoresSafeArea()) } private func readJwtAndLogin() { // Read JWT from WKWebView's localStorage via JavaScript SSOWebViewHolder.shared.readToken { jwt in guard let jwt = jwt, !jwt.isEmpty, jwt != "null" else { DispatchQueue.main.async { // Toast } return } Task { await exchangeJwt(jwt) } } } private func exchangeJwt(_ jwt: String) async { do { let result = try await AuthAPI.ssoExchange(jwtToken: jwt) await MainActor.run { dismiss() if !result.phoneBound { auth.ssoPendingToken = result.token auth.ssoPendingUserId = result.user.id auth.showingPhoneBinding = true } else { auth.saveSession(AuthSession(token: result.token, user: result.user)) onLoginSuccess() } } } catch { await MainActor.run { dismiss() } } } } // MARK: - SSO WebView Representable struct SSOWebViewRepresentable: UIViewRepresentable { func makeUIView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() config.websiteDataStore = .default() let webView = WKWebView(frame: .zero, configuration: config) webView.navigationDelegate = context.coordinator SSOWebViewHolder.shared.webView = webView if let url = URL(string: "https://d1kt.cn/login") { webView.load(URLRequest(url: url)) } return webView } func updateUIView(_ uiView: WKWebView, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator() } class Coordinator: NSObject, WKNavigationDelegate { func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { decisionHandler(.allow) } } } // MARK: - Holder for WebView reference final class SSOWebViewHolder { static let shared = SSOWebViewHolder() weak var webView: WKWebView? func readToken(completion: @escaping (String?) -> Void) { guard let webView = webView else { completion(nil) return } webView.evaluateJavaScript( "(function(){ try { return localStorage.getItem('token') || sessionStorage.getItem('token') || ''; } catch(e) { return ''; } })()" ) { result, _ in let token = (result as? String) ?? "" completion(token.isEmpty || token == "null" ? nil : token) } } } // MARK: - Phone Binding View struct PhoneBindingView: View { @ObservedObject var auth: AuthManager @Environment(\.dismiss) private var dismiss @State private var phone = "" @State private var statusText = "" @State private var isLoading = false var onLoginSuccess: () -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { Text("绑定手机号") .font(.system(size: 28, weight: .bold)) .foregroundColor(.white) Text("已通过第一课堂登录,请绑定手机号后继续") .font(.system(size: 14)) .foregroundColor(Theme.textMuted) .padding(.top, 6) .padding(.bottom, 24) fieldLabel("手机号") TextField("请输入 11 位手机号", text: $phone) .textFieldStyle(.plain) .keyboardType(.phonePad) .padding(12) .background(Theme.surface) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) Button { Task { await bindPhone() } } label: { Text(isLoading ? "绑定中..." : "绑定并继续") .frame(maxWidth: .infinity) .frame(height: 48) .background(Theme.accent) .foregroundColor(.white) .clipShape(RoundedRectangle(cornerRadius: 8)) } .padding(.top, 20) .disabled(isLoading) if !statusText.isEmpty { Text(statusText) .font(.system(size: 13)) .foregroundColor(Theme.accentLight) .padding(.top, 14) } Spacer() } .padding(.horizontal, 28) .padding(.top, 60) .background(Theme.background.ignoresSafeArea()) } private func fieldLabel(_ text: String) -> some View { Text(text) .font(.system(size: 13)) .foregroundColor(Theme.textMuted) .padding(.bottom, 4) } private func bindPhone() async { let cleaned = phone.trimmingCharacters(in: .whitespaces) guard cleaned.range(of: "^1[3-9]\\d{9}$", options: .regularExpression) != nil else { statusText = "请输入有效的 11 位手机号" return } guard let token = auth.ssoPendingToken else { statusText = "登录状态已过期,请重新登录" return } isLoading = true statusText = "绑定中..." defer { isLoading = false } do { try await AuthAPI.ssoBindPhone(token: token, phone: cleaned) auth.showingPhoneBinding = false auth.ssoPendingToken = nil let userID = auth.ssoPendingUserId auth.saveSession( AuthSession( token: token, user: AuthUser(id: userID, username: "", nickname: "") ) ) dismiss() onLoginSuccess() } catch { statusText = "绑定失败:\(error.localizedDescription)" } } }