align iOS with Android: auth, SSO login, course selection, phone binding, 4 tabs
This commit is contained in:
506
ios/OralTrainer/Views/AuthView.swift
Normal file
506
ios/OralTrainer/Views/AuthView.swift
Normal file
@@ -0,0 +1,506 @@
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user