初始提交
This commit is contained in:
482
suixinkan/Features/Auth/Views/LoginView.swift
Normal file
482
suixinkan/Features/Auth/Views/LoginView.swift
Normal file
@ -0,0 +1,482 @@
|
||||
//
|
||||
// LoginView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 登录页视图,负责展示登录表单、协议确认和多账号选择入口。
|
||||
struct LoginView: View {
|
||||
@Environment(AppSession.self) private var appSession
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(AuthAPI.self) private var authAPI
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State 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())
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
HStack {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.frame(width: AppMetrics.ControlSize.progressWidth)
|
||||
} else {
|
||||
Spacer()
|
||||
.frame(width: AppMetrics.ControlSize.progressWidth)
|
||||
}
|
||||
|
||||
Text("登录")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Spacer()
|
||||
.frame(width: AppMetrics.ControlSize.progressWidth)
|
||||
}
|
||||
.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 {
|
||||
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 {
|
||||
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()
|
||||
.environment(AppSession())
|
||||
.environment(AccountContext())
|
||||
.environment(PermissionContext())
|
||||
.environment(ToastCenter())
|
||||
.environment(AuthAPI(client: APIClient()))
|
||||
.environment(ProfileAPI(client: APIClient()))
|
||||
.environment(AccountContextAPI(client: APIClient()))
|
||||
.environment(AuthSessionCoordinator())
|
||||
}
|
||||
Reference in New Issue
Block a user