Files
mediaplayer/ios/OralTrainer/Views/AuthView.swift
2026-09-09 12:31:05 +08:00

534 lines
18 KiB
Swift

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)"
}
}
}