新增 SplashView、SplashCoordinator 与 Launch Screen 资源,移除冷启动 Lottie;LoginView 加载 App 配置。同时将 token 存储迁移至 UserDefaults,并更新相关测试与文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
476 lines
18 KiB
Swift
476 lines
18 KiB
Swift
//
|
||
// LoginView.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/18.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
/// 登录页视图,负责展示登录表单、协议确认和多账号选择入口。
|
||
struct LoginView: View {
|
||
@EnvironmentObject private var appSession: AppSession
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@EnvironmentObject private var permissionContext: PermissionContext
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.authAPI) private var authAPI
|
||
@Environment(\.appConfigAPI) private var appConfigAPI
|
||
@Environment(\.profileAPI) private var profileAPI
|
||
@Environment(\.accountContextAPI) private var accountContextAPI
|
||
@Environment(\.authSessionCoordinator) private var authSessionCoordinator
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||
@StateObject private var viewModel = LoginViewModel()
|
||
@FocusState private var focusedField: LoginField?
|
||
|
||
private var contentMaxWidth: CGFloat {
|
||
horizontalSizeClass == .regular ? 560 : .infinity
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
GeometryReader { proxy in
|
||
ZStack(alignment: .top) {
|
||
Image("LoginBackground")
|
||
.resizable()
|
||
.scaledToFill()
|
||
.ignoresSafeArea()
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
focusedField = nil
|
||
}
|
||
|
||
ScrollView(.vertical) {
|
||
loginContent
|
||
.frame(maxWidth: contentMaxWidth, alignment: .top)
|
||
.padding(.horizontal, horizontalPadding(for: proxy.size.width))
|
||
.padding(.top, topPadding(for: proxy))
|
||
.padding(.bottom, AppMetrics.Spacing.xxLarge)
|
||
.frame(maxWidth: .infinity, alignment: .top)
|
||
}
|
||
.scrollDismissesKeyboard(.interactively)
|
||
}
|
||
}
|
||
.background(Color(hex: 0x0B1220).ignoresSafeArea())
|
||
.toolbar(.hidden, for: .navigationBar)
|
||
.sheet(isPresented: showsAgreementSheetBinding) {
|
||
LoginAgreementConsentSheet(
|
||
onOpenAgreement: { title in
|
||
toastCenter.show("\(title)页面待接入")
|
||
},
|
||
onAgreeAndContinue: {
|
||
viewModel.acceptAgreement()
|
||
loginAction()
|
||
}
|
||
)
|
||
}
|
||
.sheet(item: pendingAccountSelectionBinding) { payload in
|
||
AccountSelectionView(
|
||
payload: payload,
|
||
isLoading: viewModel.isSelectingAccount,
|
||
onCancel: {
|
||
viewModel.clearPendingAccountSelection()
|
||
},
|
||
onConfirm: { account in
|
||
selectAccount(account)
|
||
}
|
||
)
|
||
}
|
||
}
|
||
.onChange(of: viewModel.username) { _ in
|
||
viewModel.normalizeUsernameCountryCodeIfNeeded()
|
||
dismissLoginToastIfNeeded()
|
||
}
|
||
.onChange(of: viewModel.password) { _ in
|
||
dismissLoginToastIfNeeded()
|
||
}
|
||
.task {
|
||
viewModel.applyPreferences(authSessionCoordinator.loginPreferences())
|
||
await viewModel.loadAppConfig(appConfigAPI: appConfigAPI)
|
||
}
|
||
}
|
||
|
||
private var loginContent: some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxLarge) {
|
||
Text("欢迎使用\n随心瞰商家版")
|
||
.font(.system(size: AppMetrics.FontSize.largeTitle, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.lineSpacing(AppMetrics.LineSpacing.title)
|
||
.accessibilityIdentifier("login.title")
|
||
|
||
loginCard
|
||
}
|
||
}
|
||
|
||
private var loginCard: some View {
|
||
VStack(spacing: 0) {
|
||
VStack(spacing: AppMetrics.Spacing.xSmall) {
|
||
textInput(title: "请输入手机号", text: usernameBinding, icon: "person")
|
||
passwordInput
|
||
}
|
||
|
||
Spacer().frame(height: AppMetrics.ControlSize.checkboxTapArea)
|
||
agreementRow
|
||
Spacer().frame(height: AppMetrics.Spacing.mediumLarge)
|
||
|
||
Button(action: loginAction) {
|
||
Text("登录")
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
||
.foregroundStyle(.white)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||
.background(viewModel.canSubmit ? AppDesign.primary : Color.gray)
|
||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||
}
|
||
.disabled(!viewModel.canSubmit || viewModel.isLoading)
|
||
.accessibilityIdentifier("login.submit")
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
.shadow(color: .black.opacity(0.12), radius: 18, x: 0, y: 8)
|
||
}
|
||
|
||
/// 计算登录内容相对安全区的顶部距离。
|
||
private func topPadding(for proxy: GeometryProxy) -> CGFloat {
|
||
max(proxy.safeAreaInsets.top + 24, 72)
|
||
}
|
||
|
||
/// 根据屏幕宽度计算登录内容的水平边距。
|
||
private func horizontalPadding(for width: CGFloat) -> CGFloat {
|
||
min(max(width * 0.07, 20), 30)
|
||
}
|
||
|
||
/// 处理登录按钮点击,完成表单校验并启动登录流程。
|
||
private func loginAction() {
|
||
focusedField = nil
|
||
|
||
if let validationError = viewModel.validateForLogin() {
|
||
if validationError == .privacyUnchecked {
|
||
viewModel.showsAgreementSheet = true
|
||
} else {
|
||
toastCenter.show(validationError.message)
|
||
focusedField = validationError.focusField
|
||
}
|
||
return
|
||
}
|
||
|
||
Task {
|
||
do {
|
||
try await globalLoading.withLoading(message: "登录中...") {
|
||
let resolution = try await viewModel.login(authAPI: authAPI)
|
||
switch resolution {
|
||
case let .completed(response):
|
||
await completeLogin(with: response)
|
||
case .needsAccountSelection:
|
||
break
|
||
}
|
||
}
|
||
} catch is CancellationError {
|
||
// Keep cancellation silent; the current task was abandoned by SwiftUI.
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 处理账号选择页的确认动作,并使用所选账号换取正式 token。
|
||
private func selectAccount(_ account: AccountSwitchAccount) {
|
||
Task {
|
||
do {
|
||
try await globalLoading.withLoading(message: "账号切换中...") {
|
||
let response = try await viewModel.selectAccount(account, authAPI: authAPI)
|
||
await completeLogin(with: response)
|
||
}
|
||
} catch is CancellationError {
|
||
// Keep cancellation silent; the current task was abandoned by SwiftUI.
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 将最终登录响应写入全局会话和账号上下文。
|
||
private func completeLogin(with response: V9AuthResponse) async {
|
||
do {
|
||
try await authSessionCoordinator.completeLogin(
|
||
with: response,
|
||
username: viewModel.normalizedUsername,
|
||
privacyAgreementAccepted: viewModel.privacyChecked,
|
||
appSession: appSession,
|
||
accountContext: accountContext,
|
||
permissionContext: permissionContext,
|
||
profileAPI: profileAPI,
|
||
accountContextAPI: accountContextAPI
|
||
)
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 输入内容变化后清除当前 Toast,避免旧错误提示停留。
|
||
private func dismissLoginToastIfNeeded() {
|
||
toastCenter.dismiss()
|
||
}
|
||
|
||
/// 构造通用文本输入框,当前用于手机号输入。
|
||
private func textInput(title: String, text: Binding<String>, icon: String) -> some View {
|
||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: AppMetrics.ControlSize.smallIcon))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
|
||
TextField(title, text: text)
|
||
.font(.system(size: AppMetrics.FontSize.body))
|
||
.textFieldStyle(.plain)
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
.tint(AppDesign.primary)
|
||
.keyboardType(.phonePad)
|
||
.textContentType(.telephoneNumber)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled(true)
|
||
.submitLabel(.next)
|
||
.focused($focusedField, equals: .username)
|
||
.onSubmit {
|
||
focusedField = .password
|
||
}
|
||
.accessibilityIdentifier("login.username")
|
||
}
|
||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||
.frame(height: AppMetrics.ControlSize.inputHeight)
|
||
.background(Color(hex: 0xF1F5F9), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)
|
||
.stroke(Color(hex: 0xDDE3EA), lineWidth: 1)
|
||
)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
focusedField = .username
|
||
}
|
||
}
|
||
|
||
private var passwordInput: some View {
|
||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||
Image(systemName: "lock")
|
||
.font(.system(size: AppMetrics.ControlSize.smallIcon))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
|
||
Group {
|
||
if viewModel.showsPassword {
|
||
TextField("请输入密码", text: passwordBinding)
|
||
} else {
|
||
SecureField("请输入密码", text: passwordBinding)
|
||
}
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.body))
|
||
.textFieldStyle(.plain)
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
.tint(AppDesign.primary)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled(true)
|
||
.submitLabel(.go)
|
||
.focused($focusedField, equals: .password)
|
||
.onSubmit {
|
||
if viewModel.canSubmit && !viewModel.isLoading {
|
||
loginAction()
|
||
}
|
||
}
|
||
|
||
Button {
|
||
viewModel.showsPassword.toggle()
|
||
} label: {
|
||
Image(viewModel.showsPassword ? "LoginPwdVisible" : "LoginPwdInvisible")
|
||
.resizable()
|
||
.frame(width: AppMetrics.ControlSize.passwordIcon, height: AppMetrics.ControlSize.passwordIcon)
|
||
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||
.frame(height: AppMetrics.ControlSize.inputHeight)
|
||
.background(Color(hex: 0xF1F5F9), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)
|
||
.stroke(Color(hex: 0xDDE3EA), lineWidth: 1)
|
||
)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
focusedField = .password
|
||
}
|
||
.accessibilityIdentifier("login.password")
|
||
}
|
||
|
||
private var agreementRow: some View {
|
||
HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) {
|
||
Button {
|
||
viewModel.privacyChecked.toggle()
|
||
} label: {
|
||
Image(viewModel.privacyChecked ? "LoginCheckboxChecked" : "LoginCheckboxUnchecked")
|
||
.resizable()
|
||
.frame(width: AppMetrics.ControlSize.checkboxIcon, height: AppMetrics.ControlSize.checkboxIcon)
|
||
.frame(width: AppMetrics.ControlSize.checkboxTapArea, height: AppMetrics.ControlSize.checkboxTapArea)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityIdentifier("login.privacy")
|
||
|
||
agreementText
|
||
.padding(.top, AppMetrics.Spacing.xxSmall)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
}
|
||
|
||
private var agreementText: some View {
|
||
ViewThatFits(in: .horizontal) {
|
||
HStack(spacing: 0) {
|
||
agreementPrefix
|
||
userAgreementButton
|
||
agreementSeparator
|
||
privacyPolicyButton
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||
HStack(spacing: 0) {
|
||
agreementPrefix
|
||
userAgreementButton
|
||
}
|
||
HStack(spacing: 0) {
|
||
agreementSeparator
|
||
privacyPolicyButton
|
||
}
|
||
}
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
}
|
||
|
||
private var agreementPrefix: some View {
|
||
Text("已阅读并同意")
|
||
.foregroundStyle(Color(hex: 0x666666))
|
||
}
|
||
|
||
private var agreementSeparator: some View {
|
||
Text("与")
|
||
.foregroundStyle(Color(hex: 0x666666))
|
||
}
|
||
|
||
private var userAgreementButton: some View {
|
||
Button("《用户协议》") {
|
||
toastCenter.show("用户协议页面待接入")
|
||
}
|
||
.foregroundStyle(Color(hex: 0x208BFF))
|
||
.accessibilityIdentifier("login.userAgreement")
|
||
}
|
||
|
||
private var privacyPolicyButton: some View {
|
||
Button("《隐私政策》") {
|
||
toastCenter.show("隐私政策页面待接入")
|
||
}
|
||
.foregroundStyle(Color(hex: 0x208BFF))
|
||
.accessibilityIdentifier("login.privacyPolicy")
|
||
}
|
||
|
||
private var usernameBinding: Binding<String> {
|
||
Binding(
|
||
get: { viewModel.username },
|
||
set: { viewModel.username = $0 }
|
||
)
|
||
}
|
||
|
||
private var passwordBinding: Binding<String> {
|
||
Binding(
|
||
get: { viewModel.password },
|
||
set: { viewModel.password = $0 }
|
||
)
|
||
}
|
||
|
||
private var showsAgreementSheetBinding: Binding<Bool> {
|
||
Binding(
|
||
get: { viewModel.showsAgreementSheet },
|
||
set: { viewModel.showsAgreementSheet = $0 }
|
||
)
|
||
}
|
||
|
||
private var pendingAccountSelectionBinding: Binding<AccountSelectionPayload?> {
|
||
Binding(
|
||
get: { viewModel.pendingAccountSelection },
|
||
set: { viewModel.pendingAccountSelection = $0 }
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 协议确认弹窗,负责在用户未勾选协议时引导确认。
|
||
private struct LoginAgreementConsentSheet: View {
|
||
private static let compactDetent = PresentationDetent.height(240)
|
||
|
||
let onOpenAgreement: (String) -> Void
|
||
let onAgreeAndContinue: () -> Void
|
||
|
||
var body: some View {
|
||
VStack(spacing: AppMetrics.Spacing.sheet) {
|
||
Text("请先阅读并同意相关协议")
|
||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
|
||
agreementDescription
|
||
|
||
Button(action: onAgreeAndContinue) {
|
||
Text("同意并继续")
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
||
.foregroundStyle(.white)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: AppMetrics.ControlSize.sheetButtonHeight)
|
||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.top, AppMetrics.Spacing.large)
|
||
.accessibilityIdentifier("login.agreement.continue")
|
||
}
|
||
.padding(.horizontal, AppMetrics.Spacing.large)
|
||
.padding(.top, AppMetrics.Spacing.sheet)
|
||
.padding(.bottom, AppMetrics.Spacing.mediumLarge)
|
||
.presentationDetents([Self.compactDetent])
|
||
}
|
||
|
||
private var agreementDescription: some View {
|
||
VStack(spacing: AppMetrics.Spacing.small) {
|
||
Text("为了保障你的账号安全和服务体验,请阅读并同意")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
.multilineTextAlignment(.center)
|
||
|
||
HStack(spacing: 0) {
|
||
agreementToken(title: "用户协议")
|
||
Text("和")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
agreementToken(title: "隐私政策")
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
|
||
/// 构造协议链接文本按钮。
|
||
private func agreementToken(title: String) -> some View {
|
||
Button {
|
||
onOpenAgreement(title)
|
||
} label: {
|
||
Text("《\(title)》")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||
.foregroundStyle(Color(hex: 0x208BFF))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
LoginView()
|
||
.environmentObject(AppSession())
|
||
.environmentObject(AccountContext())
|
||
.environmentObject(PermissionContext())
|
||
.environmentObject(ToastCenter())
|
||
.environment(\.authAPI, AuthAPI(client: APIClient()))
|
||
.environment(\.profileAPI, ProfileAPI(client: APIClient()))
|
||
.environment(\.accountContextAPI, AccountContextAPI(client: APIClient()))
|
||
.environment(\.authSessionCoordinator, AuthSessionCoordinator())
|
||
}
|