507 lines
17 KiB
Swift
507 lines
17 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Auth Manager (shared state)
|
|
|
|
@MainActor
|
|
final class AuthManager: ObservableObject {
|
|
@Published var token: String
|
|
@Published var nickname: String
|
|
@Published var showingRegister = false
|
|
@Published var showingSSO = false
|
|
@Published var showingPhoneBinding = false
|
|
@Published var ssoPendingToken: String?
|
|
|
|
init() {
|
|
let defaults = UserDefaults.standard
|
|
token = defaults.string(forKey: "oral_trainer_token") ?? ""
|
|
nickname = defaults.string(forKey: "oral_trainer_nickname") ?? ""
|
|
}
|
|
|
|
var isLoggedIn: Bool { !token.isEmpty }
|
|
|
|
func saveSession(_ session: AuthSession) {
|
|
token = session.token
|
|
nickname = session.user.nickname
|
|
let defaults = UserDefaults.standard
|
|
defaults.set(session.token, forKey: "oral_trainer_token")
|
|
defaults.set(session.user.nickname, forKey: "oral_trainer_nickname")
|
|
}
|
|
|
|
func logout() {
|
|
token = ""
|
|
nickname = ""
|
|
showingRegister = false
|
|
showingSSO = false
|
|
showingPhoneBinding = false
|
|
ssoPendingToken = nil
|
|
let defaults = UserDefaults.standard
|
|
defaults.removeObject(forKey: "oral_trainer_token")
|
|
defaults.removeObject(forKey: "oral_trainer_nickname")
|
|
}
|
|
}
|
|
|
|
// 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("选填", 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 }
|
|
|
|
isLoading = true
|
|
statusText = "注册中..."
|
|
defer { isLoading = false }
|
|
do {
|
|
let session = try await AuthAPI.register(
|
|
username: username, password: password, nickname: nickname, phone: phone
|
|
)
|
|
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.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
|
|
auth.saveSession(AuthSession(token: token, user: AuthUser(id: "", username: "", nickname: "")))
|
|
dismiss()
|
|
onLoginSuccess()
|
|
} catch {
|
|
statusText = "绑定失败:\(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|