align iOS with Android: auth, SSO login, course selection, phone binding, 4 tabs
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
AA0000000000000000000090 /* AuthAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000091 /* AuthAPI.swift */; };
|
||||
AA0000000000000000000092 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000093 /* AuthView.swift */; };
|
||||
AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; };
|
||||
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; };
|
||||
AA0000000000000000000043 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000023 /* AppDelegate.swift */; };
|
||||
@@ -33,6 +35,8 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
AA0000000000000000000091 /* AuthAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAPI.swift; sourceTree = "<group>"; };
|
||||
AA0000000000000000000093 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||
AA000000000000000000000A /* OralTrainer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OralTrainer.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AA000000000000000000001F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
AA0000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
@@ -117,6 +121,7 @@
|
||||
children = (
|
||||
AA0000000000000000000028 /* HTTPClient.swift */,
|
||||
AA0000000000000000000029 /* VideoCatalogAPI.swift */,
|
||||
AA0000000000000000000091 /* AuthAPI.swift */,
|
||||
AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
|
||||
AA000000000000000000002B /* ImitationAssessor.swift */,
|
||||
AA000000000000000000007E /* DubbingCompositor.swift */,
|
||||
@@ -139,6 +144,7 @@
|
||||
children = (
|
||||
AA000000000000000000002E /* Theme.swift */,
|
||||
AA000000000000000000002F /* RootView.swift */,
|
||||
AA0000000000000000000093 /* AuthView.swift */,
|
||||
AA0000000000000000000030 /* TrainingView.swift */,
|
||||
AA0000000000000000000031 /* PlayerSectionView.swift */,
|
||||
AA0000000000000000000032 /* CatalogView.swift */,
|
||||
@@ -248,6 +254,8 @@
|
||||
AA0000000000000000000050 /* TrainingView.swift in Sources */,
|
||||
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */,
|
||||
AA0000000000000000000052 /* CatalogView.swift in Sources */,
|
||||
AA0000000000000000000090 /* AuthAPI.swift in Sources */,
|
||||
AA0000000000000000000092 /* AuthView.swift in Sources */,
|
||||
AA0000000000000000000053 /* TestView.swift in Sources */,
|
||||
AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */,
|
||||
);
|
||||
|
||||
172
ios/OralTrainer/Services/AuthAPI.swift
Normal file
172
ios/OralTrainer/Services/AuthAPI.swift
Normal file
@@ -0,0 +1,172 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct AuthUser: Codable {
|
||||
let id: String
|
||||
let username: String
|
||||
let nickname: String
|
||||
}
|
||||
|
||||
struct AuthSession: Codable {
|
||||
let token: String
|
||||
let user: AuthUser
|
||||
}
|
||||
|
||||
struct SsoExchangeResult: Codable {
|
||||
let token: String
|
||||
let user: AuthUser
|
||||
let phoneBound: Bool
|
||||
let isNewUser: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case token, user
|
||||
case phoneBound = "phone_bound"
|
||||
case isNewUser = "is_new_user"
|
||||
}
|
||||
}
|
||||
|
||||
struct Course: Identifiable, Codable {
|
||||
let videoHash: String
|
||||
let title: String
|
||||
let durationMs: Int64?
|
||||
let language: String?
|
||||
let sentenceCount: Int
|
||||
let streamUrl: String
|
||||
let enrolled: Bool
|
||||
|
||||
var id: String { videoHash }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case title, language, enrolled
|
||||
case videoHash = "video_hash"
|
||||
case durationMs = "duration_ms"
|
||||
case sentenceCount = "sentence_count"
|
||||
case streamUrl = "stream_url"
|
||||
}
|
||||
|
||||
func toVideoSummary() -> TrainingVideoSummary {
|
||||
TrainingVideoSummary(
|
||||
videoHash: videoHash,
|
||||
title: title,
|
||||
streamUrl: streamUrl,
|
||||
durationMs: durationMs,
|
||||
sizeBytes: 0,
|
||||
language: language,
|
||||
sentenceCount: sentenceCount,
|
||||
status: "ready"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth Error
|
||||
|
||||
enum AuthError: LocalizedError {
|
||||
case badStatus(Int, String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .badStatus(let code, let body):
|
||||
return "HTTP \(code): \(body.prefix(200))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth API
|
||||
|
||||
enum AuthAPI {
|
||||
private static var baseURL: String { AppConfig.serverBaseURL }
|
||||
|
||||
// MARK: Login / Register
|
||||
|
||||
static func login(username: String, password: String) async throws -> AuthSession {
|
||||
try await authRequest(path: "/api/v1/auth/login", body: [
|
||||
"username": username,
|
||||
"password": password,
|
||||
])
|
||||
}
|
||||
|
||||
static func register(
|
||||
username: String,
|
||||
password: String,
|
||||
nickname: String,
|
||||
phone: String
|
||||
) async throws -> AuthSession {
|
||||
try await authRequest(path: "/api/v1/auth/register", body: [
|
||||
"username": username,
|
||||
"password": password,
|
||||
"nickname": nickname,
|
||||
"phone": phone,
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: SSO
|
||||
|
||||
static func ssoExchange(jwtToken: String) async throws -> SsoExchangeResult {
|
||||
let url = URL(string: "\(baseURL)/api/v1/auth/sso/exchange")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: ["access_token": jwtToken])
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw AuthError.badStatus(status, body)
|
||||
}
|
||||
return try JSONDecoder().decode(SsoExchangeResult.self, from: data)
|
||||
}
|
||||
|
||||
static func ssoBindPhone(token: String, phone: String) async throws {
|
||||
let url = URL(string: "\(baseURL)/api/v1/auth/sso/bind-phone")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: ["phone": phone])
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw AuthError.badStatus(status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Courses
|
||||
|
||||
static func courses(token: String) async throws -> [Course] {
|
||||
let url = URL(string: "\(baseURL)/api/v1/courses")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw AuthError.badStatus(status, body)
|
||||
}
|
||||
return try JSONDecoder().decode([Course].self, from: data)
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private static func authRequest(path: String, body: [String: Any]) async throws -> AuthSession {
|
||||
let url = URL(string: "\(baseURL)\(path)")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw AuthError.badStatus(status, body)
|
||||
}
|
||||
return try JSONDecoder().decode(AuthSession.self, from: data)
|
||||
}
|
||||
}
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,40 +3,77 @@ import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct RootView: View {
|
||||
@StateObject private var auth = AuthManager()
|
||||
@StateObject private var controller = PlayerController()
|
||||
@StateObject private var catalogVM = CatalogViewModel()
|
||||
@State private var activeModule: Module = .train
|
||||
@State private var activeModule: Module = .course
|
||||
@State private var continuousPlayback = false
|
||||
@State private var showImporter = false
|
||||
@State private var showFullscreenPlayer = false
|
||||
@State private var courses: [Course] = []
|
||||
@State private var currentCourse: Course?
|
||||
@State private var courseStatusText = "正在同步"
|
||||
@State private var showCourseGuardToast = false
|
||||
|
||||
enum Module {
|
||||
case course
|
||||
case train
|
||||
case test
|
||||
case mine
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if auth.isLoggedIn {
|
||||
mainContent
|
||||
} else {
|
||||
AuthView(auth: auth) {
|
||||
Task { await loadCourses() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
ZStack {
|
||||
Theme.background.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
moduleTabs
|
||||
if activeModule == .train {
|
||||
switch activeModule {
|
||||
case .course:
|
||||
coursePage
|
||||
case .train:
|
||||
TrainingView(
|
||||
controller: controller,
|
||||
catalogVM: catalogVM,
|
||||
continuousPlayback: $continuousPlayback,
|
||||
onFullscreen: enterFullscreen
|
||||
)
|
||||
} else {
|
||||
case .test:
|
||||
TestView(controller: controller, onFullscreen: enterFullscreen)
|
||||
case .mine:
|
||||
minePage
|
||||
}
|
||||
}
|
||||
|
||||
if showCourseGuardToast {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("请先选择课程")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(Theme.overlay)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
Spacer().frame(height: 80)
|
||||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.onAppear {
|
||||
catalogVM.controller = controller
|
||||
controller.setContinuousPlayback(continuousPlayback)
|
||||
if auth.isLoggedIn && courses.isEmpty {
|
||||
Task { await loadCourses() }
|
||||
}
|
||||
}
|
||||
.onChange(of: activeModule) { module in
|
||||
if module == .train {
|
||||
@@ -70,18 +107,24 @@ struct RootView: View {
|
||||
Text("八哥口语")
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
Text("外语跟读训练神器")
|
||||
Text(auth.nickname.isEmpty ? "外语跟读训练神器" : "你好,\(auth.nickname)")
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
}
|
||||
Spacer()
|
||||
headerButton("导入") {
|
||||
showImporter = true
|
||||
if activeModule == .train {
|
||||
headerButton("导入") {
|
||||
showImporter = true
|
||||
}
|
||||
}
|
||||
headerButton("刷新") {
|
||||
Task {
|
||||
await catalogVM.load()
|
||||
}
|
||||
Task { await loadCourses() }
|
||||
}
|
||||
headerButton("退出") {
|
||||
auth.logout()
|
||||
currentCourse = nil
|
||||
courses = []
|
||||
activeModule = .course
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
@@ -105,30 +148,150 @@ struct RootView: View {
|
||||
|
||||
private var moduleTabs: some View {
|
||||
HStack(spacing: 0) {
|
||||
moduleTab("课程", module: .course)
|
||||
moduleTab("训练", module: .train)
|
||||
moduleTab("测试", module: .test)
|
||||
moduleTab("我的", module: .mine)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func moduleTab(_ label: String, module: Module) -> some View {
|
||||
let selected = activeModule == module
|
||||
return Button {
|
||||
activeModule = module
|
||||
Button {
|
||||
if module != .course && currentCourse == nil {
|
||||
showCourseGuardToast = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
showCourseGuardToast = false
|
||||
}
|
||||
} else {
|
||||
activeModule = module
|
||||
}
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundColor(selected ? .white : Theme.textMuted)
|
||||
.foregroundColor(activeModule == module ? .white : Theme.textMuted)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 38)
|
||||
.background(selected ? Theme.buttonBlue : Theme.surface)
|
||||
.background(activeModule == module ? Theme.buttonBlue : Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(2)
|
||||
}
|
||||
|
||||
// MARK: - Course Page
|
||||
|
||||
private var coursePage: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("课程首页")
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
Text("选择一门课程开始训练")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 4)
|
||||
Text(courseStatusText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
if courses.isEmpty {
|
||||
Text("暂无课程")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
} else {
|
||||
LazyVStack(spacing: 10) {
|
||||
ForEach(courses) { course in
|
||||
courseCard(course)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private func courseCard(_ course: Course) -> some View {
|
||||
let isSelected = currentCourse?.videoHash == course.videoHash
|
||||
return Button {
|
||||
selectCourse(course)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(course.title)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(isSelected ? Theme.textDark : .white)
|
||||
.lineLimit(2)
|
||||
Text("\(course.enrolled ? "已选修" : "未选修") · \(course.sentenceCount) 句\(isSelected ? " · 当前课程" : "")")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(isSelected ? Theme.accentDeep : Theme.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.background(isSelected ? Theme.selected : Theme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isSelected ? Theme.accent : Theme.border, lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func selectCourse(_ course: Course) {
|
||||
currentCourse = course
|
||||
let video = course.toVideoSummary()
|
||||
catalogVM.select(video)
|
||||
activeModule = .train
|
||||
}
|
||||
|
||||
// MARK: - Mine Page
|
||||
|
||||
private var minePage: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(auth.nickname.isEmpty ? "我的学习" : auth.nickname)
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
|
||||
if let course = currentCourse {
|
||||
Text("当前课程:\(course.title)")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
Text("此页面显示当前课程的测试记录和配音分享")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.textMuted)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Load Courses
|
||||
|
||||
private func loadCourses() async {
|
||||
courseStatusText = "正在同步"
|
||||
do {
|
||||
courses = try await AuthAPI.courses(token: auth.token)
|
||||
courseStatusText = "\(courses.count) 个课程"
|
||||
} catch {
|
||||
courseStatusText = "课程加载失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fullscreen
|
||||
|
||||
private func enterFullscreen() {
|
||||
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
|
||||
updateSupportedInterfaceOrientations()
|
||||
|
||||
Reference in New Issue
Block a user