Initial commit
This commit is contained in:
216
suixinkan/Features/Auth/Views/AccountSelectionView.swift
Normal file
216
suixinkan/Features/Auth/Views/AccountSelectionView.swift
Normal file
@ -0,0 +1,216 @@
|
||||
//
|
||||
// AccountSelectionView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 账号选择视图,展示 v9 登录返回的多个景区/门店账号并提交用户选择。
|
||||
struct AccountSelectionView: View {
|
||||
let payload: AccountSelectionPayload
|
||||
let isLoading: Bool
|
||||
let onCancel: () -> Void
|
||||
let onConfirm: (AccountSwitchAccount) -> Void
|
||||
|
||||
@State private var selectedAccountId: String?
|
||||
|
||||
private var selectedAccount: AccountSwitchAccount? {
|
||||
payload.accounts.first { $0.id == selectedAccountId }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
accountList
|
||||
bottomBar
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FB).ignoresSafeArea())
|
||||
.navigationTitle("选择账号")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消", action: onCancel)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
selectedAccountId = selectedAccountId ?? payload.accounts.first?.id
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(isLoading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var accountList: some View {
|
||||
if payload.accounts.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"暂无可用账号",
|
||||
systemImage: "person.crop.circle.badge.exclamationmark",
|
||||
description: Text("当前账号没有可用的景区账号或门店账号,请联系管理员。")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(AppMetrics.Spacing.xLarge)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(payload.accounts) { account in
|
||||
accountCard(account)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.padding(.bottom, 90)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
Button {
|
||||
guard let selectedAccount else { return }
|
||||
onConfirm(selectedAccount)
|
||||
} label: {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
Text("进入系统")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
.background(canConfirm ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.padding(.vertical, AppMetrics.FontSize.subheadline)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canConfirm)
|
||||
}
|
||||
.background(.white)
|
||||
}
|
||||
|
||||
private var canConfirm: Bool {
|
||||
selectedAccount != nil && !isLoading
|
||||
}
|
||||
|
||||
/// 渲染单个账号卡片,并处理选中状态。
|
||||
private func accountCard(_ account: AccountSwitchAccount) -> some View {
|
||||
let selected = selectedAccountId == account.id
|
||||
return Button {
|
||||
selectedAccountId = account.id
|
||||
} label: {
|
||||
HStack(spacing: AppMetrics.FontSize.footnote) {
|
||||
avatarFallback(account)
|
||||
.frame(width: AppMetrics.ControlSize.primaryButtonHeight, height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(account.title.isEmpty ? account.accountTypeLabel : account.title)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
if account.isCurrent {
|
||||
tag("当前", foreground: AppDesign.primary, background: AppDesign.primarySoft)
|
||||
}
|
||||
}
|
||||
|
||||
if !account.subtitle.isEmpty {
|
||||
Text(account.subtitle)
|
||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if !account.phone.isEmpty {
|
||||
Text(account.phone)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(Color(hex: 0x9AA1AA))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
VStack(alignment: .trailing, spacing: AppMetrics.Spacing.xSmall) {
|
||||
tag(
|
||||
account.isStoreUser ? "门店" : "景区",
|
||||
foreground: account.isStoreUser ? Color(hex: 0x0F9F6E) : Color(hex: 0x7C3AED),
|
||||
background: account.isStoreUser ? Color(hex: 0xE8F8F1) : Color(hex: 0xF3ECFF)
|
||||
)
|
||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: AppMetrics.ControlSize.passwordIcon, weight: .semibold))
|
||||
.foregroundStyle(selected ? AppDesign.primary : Color(hex: 0xB6BECA))
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.FontSize.subheadline)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)
|
||||
.stroke(selected ? AppDesign.primary : Color(hex: 0xE6ECF4), lineWidth: selected ? 1.4 : 1)
|
||||
}
|
||||
.shadow(color: Color(hex: 0xC8D7EA, alpha: selected ? 0.24 : 0.12), radius: selected ? 10 : 6, x: 0, y: 5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
/// 构造账号头像占位视图,按景区或门店账号展示不同标识。
|
||||
private func avatarFallback(_ account: AccountSwitchAccount) -> some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(account.isStoreUser ? Color(hex: 0xE8F8F1) : Color(hex: 0xF3ECFF))
|
||||
Text(account.isStoreUser ? "店" : "景")
|
||||
.font(.system(size: AppMetrics.FontSize.callout, weight: .semibold))
|
||||
.foregroundStyle(account.isStoreUser ? Color(hex: 0x0F9F6E) : Color(hex: 0x7C3AED))
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造账号类型和当前账号标记使用的胶囊标签。
|
||||
private func tag(_ text: String, foreground: Color, background: Color) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(foreground)
|
||||
.padding(.horizontal, 7)
|
||||
.frame(height: AppMetrics.Spacing.sheet)
|
||||
.background(background, in: RoundedRectangle(cornerRadius: AppMetrics.Spacing.xxSmall))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AccountSelectionView(
|
||||
payload: AccountSelectionPayload(
|
||||
tempToken: "token",
|
||||
accounts: [
|
||||
AccountSwitchAccount(
|
||||
accountType: V9ScenicUser.accountTypeValue,
|
||||
businessUserId: 1,
|
||||
title: "西湖景区",
|
||||
subtitle: "摄影师",
|
||||
phone: "13800000000",
|
||||
avatar: "",
|
||||
scenicName: "西湖景区",
|
||||
storeId: nil,
|
||||
storeName: "",
|
||||
scenicId: 100,
|
||||
isCurrent: false
|
||||
),
|
||||
AccountSwitchAccount(
|
||||
accountType: V9StoreUser.accountTypeValue,
|
||||
businessUserId: 2,
|
||||
title: "东门门店",
|
||||
subtitle: "西湖景区 · 店长",
|
||||
phone: "13900000000",
|
||||
avatar: "",
|
||||
scenicName: "西湖景区",
|
||||
storeId: 200,
|
||||
storeName: "东门门店",
|
||||
scenicId: 100,
|
||||
isCurrent: true
|
||||
)
|
||||
]
|
||||
),
|
||||
isLoading: false,
|
||||
onCancel: {},
|
||||
onConfirm: { _ 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