align iOS with Android: auth, SSO login, course selection, phone binding, 4 tabs
This commit is contained in:
@@ -19,7 +19,7 @@ android {
|
|||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.2"
|
versionName = "0.1.3"
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; };
|
||||||
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; };
|
AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; };
|
||||||
AA0000000000000000000043 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000023 /* AppDelegate.swift */; };
|
AA0000000000000000000043 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000023 /* AppDelegate.swift */; };
|
||||||
@@ -33,6 +35,8 @@
|
|||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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; };
|
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>"; };
|
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>"; };
|
AA0000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
@@ -117,6 +121,7 @@
|
|||||||
children = (
|
children = (
|
||||||
AA0000000000000000000028 /* HTTPClient.swift */,
|
AA0000000000000000000028 /* HTTPClient.swift */,
|
||||||
AA0000000000000000000029 /* VideoCatalogAPI.swift */,
|
AA0000000000000000000029 /* VideoCatalogAPI.swift */,
|
||||||
|
AA0000000000000000000091 /* AuthAPI.swift */,
|
||||||
AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
|
AA000000000000000000002A /* SentenceBoundaryAPI.swift */,
|
||||||
AA000000000000000000002B /* ImitationAssessor.swift */,
|
AA000000000000000000002B /* ImitationAssessor.swift */,
|
||||||
AA000000000000000000007E /* DubbingCompositor.swift */,
|
AA000000000000000000007E /* DubbingCompositor.swift */,
|
||||||
@@ -139,6 +144,7 @@
|
|||||||
children = (
|
children = (
|
||||||
AA000000000000000000002E /* Theme.swift */,
|
AA000000000000000000002E /* Theme.swift */,
|
||||||
AA000000000000000000002F /* RootView.swift */,
|
AA000000000000000000002F /* RootView.swift */,
|
||||||
|
AA0000000000000000000093 /* AuthView.swift */,
|
||||||
AA0000000000000000000030 /* TrainingView.swift */,
|
AA0000000000000000000030 /* TrainingView.swift */,
|
||||||
AA0000000000000000000031 /* PlayerSectionView.swift */,
|
AA0000000000000000000031 /* PlayerSectionView.swift */,
|
||||||
AA0000000000000000000032 /* CatalogView.swift */,
|
AA0000000000000000000032 /* CatalogView.swift */,
|
||||||
@@ -248,6 +254,8 @@
|
|||||||
AA0000000000000000000050 /* TrainingView.swift in Sources */,
|
AA0000000000000000000050 /* TrainingView.swift in Sources */,
|
||||||
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */,
|
AA0000000000000000000051 /* PlayerSectionView.swift in Sources */,
|
||||||
AA0000000000000000000052 /* CatalogView.swift in Sources */,
|
AA0000000000000000000052 /* CatalogView.swift in Sources */,
|
||||||
|
AA0000000000000000000090 /* AuthAPI.swift in Sources */,
|
||||||
|
AA0000000000000000000092 /* AuthView.swift in Sources */,
|
||||||
AA0000000000000000000053 /* TestView.swift in Sources */,
|
AA0000000000000000000053 /* TestView.swift in Sources */,
|
||||||
AA0000000000000000000054 /* FullscreenPlayerView.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
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
|
@StateObject private var auth = AuthManager()
|
||||||
@StateObject private var controller = PlayerController()
|
@StateObject private var controller = PlayerController()
|
||||||
@StateObject private var catalogVM = CatalogViewModel()
|
@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 continuousPlayback = false
|
||||||
@State private var showImporter = false
|
@State private var showImporter = false
|
||||||
@State private var showFullscreenPlayer = 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 {
|
enum Module {
|
||||||
|
case course
|
||||||
case train
|
case train
|
||||||
case test
|
case test
|
||||||
|
case mine
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if auth.isLoggedIn {
|
||||||
|
mainContent
|
||||||
|
} else {
|
||||||
|
AuthView(auth: auth) {
|
||||||
|
Task { await loadCourses() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainContent: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Theme.background.ignoresSafeArea()
|
Theme.background.ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
header
|
header
|
||||||
moduleTabs
|
moduleTabs
|
||||||
if activeModule == .train {
|
switch activeModule {
|
||||||
|
case .course:
|
||||||
|
coursePage
|
||||||
|
case .train:
|
||||||
TrainingView(
|
TrainingView(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
catalogVM: catalogVM,
|
catalogVM: catalogVM,
|
||||||
continuousPlayback: $continuousPlayback,
|
continuousPlayback: $continuousPlayback,
|
||||||
onFullscreen: enterFullscreen
|
onFullscreen: enterFullscreen
|
||||||
)
|
)
|
||||||
} else {
|
case .test:
|
||||||
TestView(controller: controller, onFullscreen: enterFullscreen)
|
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)
|
.preferredColorScheme(.dark)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
catalogVM.controller = controller
|
if auth.isLoggedIn && courses.isEmpty {
|
||||||
controller.setContinuousPlayback(continuousPlayback)
|
Task { await loadCourses() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: activeModule) { module in
|
.onChange(of: activeModule) { module in
|
||||||
if module == .train {
|
if module == .train {
|
||||||
@@ -70,18 +107,24 @@ struct RootView: View {
|
|||||||
Text("八哥口语")
|
Text("八哥口语")
|
||||||
.font(.system(size: 32, weight: .bold))
|
.font(.system(size: 32, weight: .bold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
Text("外语跟读训练神器")
|
Text(auth.nickname.isEmpty ? "外语跟读训练神器" : "你好,\(auth.nickname)")
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundColor(Theme.textMuted)
|
.foregroundColor(Theme.textMuted)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
headerButton("导入") {
|
if activeModule == .train {
|
||||||
showImporter = true
|
headerButton("导入") {
|
||||||
|
showImporter = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headerButton("刷新") {
|
headerButton("刷新") {
|
||||||
Task {
|
Task { await loadCourses() }
|
||||||
await catalogVM.load()
|
}
|
||||||
}
|
headerButton("退出") {
|
||||||
|
auth.logout()
|
||||||
|
currentCourse = nil
|
||||||
|
courses = []
|
||||||
|
activeModule = .course
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 18)
|
||||||
@@ -105,30 +148,150 @@ struct RootView: View {
|
|||||||
|
|
||||||
private var moduleTabs: some View {
|
private var moduleTabs: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
|
moduleTab("课程", module: .course)
|
||||||
moduleTab("训练", module: .train)
|
moduleTab("训练", module: .train)
|
||||||
moduleTab("测试", module: .test)
|
moduleTab("测试", module: .test)
|
||||||
|
moduleTab("我的", module: .mine)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func moduleTab(_ label: String, module: Module) -> some View {
|
private func moduleTab(_ label: String, module: Module) -> some View {
|
||||||
let selected = activeModule == module
|
Button {
|
||||||
return Button {
|
if module != .course && currentCourse == nil {
|
||||||
activeModule = module
|
showCourseGuardToast = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||||
|
showCourseGuardToast = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activeModule = module
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 15, weight: .bold))
|
.font(.system(size: 15, weight: .bold))
|
||||||
.foregroundColor(selected ? .white : Theme.textMuted)
|
.foregroundColor(activeModule == module ? .white : Theme.textMuted)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 38)
|
.frame(height: 38)
|
||||||
.background(selected ? Theme.buttonBlue : Theme.surface)
|
.background(activeModule == module ? Theme.buttonBlue : Theme.surface)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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() {
|
private func enterFullscreen() {
|
||||||
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
|
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
|
||||||
updateSupportedInterfaceOrientations()
|
updateSupportedInterfaceOrientations()
|
||||||
|
|||||||
Reference in New Issue
Block a user