align iOS with Android: auth, SSO login, course selection, phone binding, 4 tabs

This commit is contained in:
2026-08-31 20:45:04 +08:00
parent f9f65988b7
commit b5934f21f5
5 changed files with 867 additions and 18 deletions

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

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

View File

@@ -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()