// // 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(\.globalLoading) private var globalLoading @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) { 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, 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 { Binding( get: { viewModel.username }, set: { viewModel.username = $0 } ) } private var passwordBinding: Binding { Binding( get: { viewModel.password }, set: { viewModel.password = $0 } ) } private var showsAgreementSheetBinding: Binding { Binding( get: { viewModel.showsAgreementSheet }, set: { viewModel.showsAgreementSheet = $0 } ) } private var pendingAccountSelectionBinding: Binding { 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()) }