Initial commit
This commit is contained in:
44
suixinkan/Features/Auth/API/AuthAPI.swift
Normal file
44
suixinkan/Features/Auth/API/AuthAPI.swift
Normal file
@ -0,0 +1,44 @@
|
||||
//
|
||||
// AuthAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 登录认证 API,封装 v9 登录和账号选择相关接口。
|
||||
final class AuthAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
|
||||
/// 初始化登录 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 使用手机号和密码发起 v9 登录,返回临时 token 及可选账号列表。
|
||||
func login(username: String, password: String) async throws -> V9AuthResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/v9/login",
|
||||
body: LoginRequest(username: username, password: password)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 选择具体景区或门店账号,换取正式登录 token。
|
||||
func setUser(_ request: SetUserRequest, tokenOverride: String? = nil) async throws -> V9AuthResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/v9/set-user",
|
||||
body: request
|
||||
),
|
||||
tokenOverride: tokenOverride
|
||||
)
|
||||
}
|
||||
}
|
||||
63
suixinkan/Features/Auth/Auth.md
Normal file
63
suixinkan/Features/Auth/Auth.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Auth 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Auth 模块负责登录页、手机号密码登录、多账号选择和账号选择后的正式登录确认。
|
||||
|
||||
该模块只处理登录流程本身:
|
||||
- 表单输入、校验和协议确认。
|
||||
- 调用 v9 登录接口获取临时 token 和账号列表。
|
||||
- 单账号时自动调用 set-user。
|
||||
- 多账号时展示账号选择页。
|
||||
- 选择账号后调用 set-user 换取正式 token。
|
||||
|
||||
正式 token 存储、账号快照和全局登录态写入由 App 模块的 `AuthSessionCoordinator` 统一处理。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `LoginView`:登录页 UI,展示手机号、密码、协议确认和登录按钮。
|
||||
- `AccountSelectionView`:多账号选择弹窗,展示景区账号和门店账号。
|
||||
- `LoginViewModel`:维护表单状态、校验状态、登录请求状态和多账号选择状态。
|
||||
- `AuthAPI`:封装 `/api/app/v9/login` 和 `/api/app/v9/set-user`。
|
||||
- `LoginRequest`:登录请求体。
|
||||
- `SetUserRequest`:账号选择请求体。
|
||||
- `V9AuthResponse`:v9 登录和 set-user 响应。
|
||||
- `V9ScenicUser` / `V9StoreUser`:后端返回的景区账号和门店账号。
|
||||
- `AccountSwitchAccount`:统一后的可选择账号模型。
|
||||
|
||||
## 登录流程
|
||||
|
||||
1. 用户输入手机号和密码。
|
||||
2. `LoginViewModel.validateForLogin` 校验手机号、密码和协议勾选状态。
|
||||
3. 未勾选协议时,`LoginView` 弹出协议确认 Sheet。
|
||||
4. 校验通过后,`LoginViewModel.login` 调用 `AuthAPI.login`。
|
||||
5. 登录接口返回临时 token 和可用账号列表。
|
||||
6. `LoginViewModel` 过滤掉业务账号 ID 无效的账号。
|
||||
7. 没有可用账号时抛出 `LoginFlowError.noAvailableAccount`。
|
||||
8. 只有一个账号时,自动调用 `AuthAPI.setUser`,并返回 `.completed`。
|
||||
9. 多个账号时,保存 `AccountSelectionPayload`,并返回 `.needsAccountSelection`。
|
||||
10. `LoginView` 展示 `AccountSelectionView`。
|
||||
11. 用户确认账号后,`LoginViewModel.selectAccount` 使用临时 token 调用 set-user。
|
||||
12. set-user 返回正式 token 后,`LoginView` 调用 `AuthSessionCoordinator.completeLogin` 写入 token,并同步用户资料、角色权限、景区和门店。
|
||||
|
||||
## 账号模型转换
|
||||
|
||||
后端景区账号和门店账号字段不完全一致,统一转换为 `AccountSwitchAccount`:
|
||||
- 景区账号通过 `ssUserId`、`scenicUserId`、`userId`、`id` 兜底生成业务账号 ID。
|
||||
- 门店账号通过 `storeUserId`、`userId`、`id` 兜底生成业务账号 ID。
|
||||
- `AccountSwitchAccount.toSetUserRequest` 根据账号类型生成 `store_user_id` 或 `ss_user_id`。
|
||||
|
||||
`V9AuthResponse` 还会派生:
|
||||
- `accounts`:合并后的账号选择列表。
|
||||
- `primaryProfile`:登录后用于全局展示的账号资料。
|
||||
- `scenicScopes`:当前账号可用景区作用域。
|
||||
- `storeScopes`:当前账号可用门店作用域。
|
||||
|
||||
## 缓存规则
|
||||
|
||||
Auth 模块不直接写缓存。登录成功后的缓存由 `AuthSessionCoordinator` 负责:
|
||||
- 正式 token 写 Keychain。
|
||||
- 上次手机号和协议状态写 UserDefaults。
|
||||
- 账号资料、当前角色和业务作用域写入账号快照。
|
||||
|
||||
临时 token 只存在于 `AccountSelectionPayload`,不落盘。
|
||||
409
suixinkan/Features/Auth/Models/AuthModels.swift
Normal file
409
suixinkan/Features/Auth/Models/AuthModels.swift
Normal file
@ -0,0 +1,409 @@
|
||||
//
|
||||
// AuthModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 登录请求实体,表示手机号密码登录接口需要的参数。
|
||||
struct LoginRequest: Encodable {
|
||||
let username: String
|
||||
let type: Int
|
||||
let password: String
|
||||
let mobile: String
|
||||
let code: String
|
||||
|
||||
/// 创建手机号密码登录请求,并填充后端要求的默认字段。
|
||||
init(username: String, password: String) {
|
||||
self.username = username
|
||||
self.type = 1
|
||||
self.password = password
|
||||
self.mobile = ""
|
||||
self.code = ""
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号选择请求实体,表示 set-user 接口需要的景区账号或门店账号 ID。
|
||||
struct SetUserRequest: Encodable, Equatable {
|
||||
let storeUserId: Int?
|
||||
let ssUserId: Int?
|
||||
|
||||
/// set-user 请求的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case storeUserId = "store_user_id"
|
||||
case ssUserId = "ss_user_id"
|
||||
}
|
||||
|
||||
/// 创建账号选择请求,门店账号传 storeUserId,景区账号传 ssUserId。
|
||||
init(storeUserId: Int? = nil, ssUserId: Int? = nil) {
|
||||
self.storeUserId = storeUserId
|
||||
self.ssUserId = ssUserId
|
||||
}
|
||||
}
|
||||
|
||||
/// 可切换账号实体,统一表示登录返回的景区账号和门店账号。
|
||||
struct AccountSwitchAccount: Identifiable, Hashable {
|
||||
let accountType: String
|
||||
let businessUserId: Int
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let phone: String
|
||||
let avatar: String
|
||||
let scenicName: String
|
||||
let storeId: Int?
|
||||
let storeName: String
|
||||
let scenicId: Int?
|
||||
let isCurrent: Bool
|
||||
|
||||
var id: String {
|
||||
"\(accountType)_\(businessUserId)"
|
||||
}
|
||||
|
||||
var isStoreUser: Bool {
|
||||
accountType == V9StoreUser.accountTypeValue
|
||||
}
|
||||
|
||||
var accountTypeLabel: String {
|
||||
isStoreUser ? "门店账号" : "景区账号"
|
||||
}
|
||||
|
||||
/// 转换为 set-user 接口请求参数。
|
||||
func toSetUserRequest() -> SetUserRequest {
|
||||
if isStoreUser {
|
||||
return SetUserRequest(storeUserId: businessUserId)
|
||||
}
|
||||
return SetUserRequest(ssUserId: businessUserId)
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录账号选择载荷,保存临时 token 和待用户选择的账号列表。
|
||||
struct AccountSelectionPayload: Equatable, Identifiable {
|
||||
let id = UUID()
|
||||
let tempToken: String
|
||||
let accounts: [AccountSwitchAccount]
|
||||
|
||||
var hasTempToken: Bool {
|
||||
!tempToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
/// v9 登录响应实体,包含 token、景区账号列表和门店账号列表。
|
||||
struct V9AuthResponse: Decodable, Equatable {
|
||||
let token: String
|
||||
let scenicUsers: [V9ScenicUser]
|
||||
let storeUsers: [V9StoreUser]
|
||||
|
||||
/// 合并景区账号和门店账号,供账号选择页展示。
|
||||
var accounts: [AccountSwitchAccount] {
|
||||
scenicUsers.map { $0.toAccountSwitchAccount() } + storeUsers.map { $0.toAccountSwitchAccount() }
|
||||
}
|
||||
|
||||
/// 提取当前或首个账号作为全局账号资料。
|
||||
var primaryProfile: AccountProfile? {
|
||||
if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first {
|
||||
return AccountProfile(
|
||||
userId: String(storeUser.userId),
|
||||
displayName: storeUser.displayName,
|
||||
phone: storeUser.phone.nonEmpty,
|
||||
avatarURL: storeUser.avatar.nonEmpty
|
||||
)
|
||||
}
|
||||
|
||||
if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first {
|
||||
return AccountProfile(
|
||||
userId: String(scenicUser.userId),
|
||||
displayName: scenicUser.displayName,
|
||||
phone: scenicUser.phone.nonEmpty,
|
||||
avatarURL: nil
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 提取登录响应中的景区作用域,并按当前账号优先排序。
|
||||
var scenicScopes: [BusinessScope] {
|
||||
var seen = Set<Int>()
|
||||
let scenicFromScenicUsers = scenicUsers
|
||||
.sorted { $0.isCurrent && !$1.isCurrent }
|
||||
.compactMap { user -> BusinessScope? in
|
||||
guard user.scenicId > 0, seen.insert(user.scenicId).inserted else { return nil }
|
||||
return BusinessScope(id: user.scenicId, name: user.scenicName, kind: .scenic)
|
||||
}
|
||||
let scenicFromStoreUsers = storeUsers
|
||||
.sorted { $0.isCurrent && !$1.isCurrent }
|
||||
.compactMap { user -> BusinessScope? in
|
||||
guard user.scenicId > 0, seen.insert(user.scenicId).inserted else { return nil }
|
||||
return BusinessScope(id: user.scenicId, name: user.scenicName, kind: .scenic)
|
||||
}
|
||||
return scenicFromScenicUsers + scenicFromStoreUsers
|
||||
}
|
||||
|
||||
/// 提取登录响应中的门店作用域,并按当前账号优先排序。
|
||||
var storeScopes: [BusinessScope] {
|
||||
var seen = Set<Int>()
|
||||
return storeUsers
|
||||
.sorted { $0.isCurrent && !$1.isCurrent }
|
||||
.compactMap { user -> BusinessScope? in
|
||||
guard user.storeId > 0, seen.insert(user.storeId).inserted else { return nil }
|
||||
return BusinessScope(
|
||||
id: user.storeId,
|
||||
name: user.storeName,
|
||||
kind: .store,
|
||||
parentScenicId: user.scenicId > 0 ? user.scenicId : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// v9 登录响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case token
|
||||
case scenicUsers = "scenic_users"
|
||||
case storeUsers = "store_users"
|
||||
}
|
||||
|
||||
/// 创建空响应或测试响应。
|
||||
init(token: String = "", scenicUsers: [V9ScenicUser] = [], storeUsers: [V9StoreUser] = []) {
|
||||
self.token = token
|
||||
self.scenicUsers = scenicUsers
|
||||
self.storeUsers = storeUsers
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容后端账号数组缺失的情况。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
token = try container.decodeLossyString(forKey: .token)
|
||||
scenicUsers = (try? container.decodeIfPresent([V9ScenicUser].self, forKey: .scenicUsers)) ?? []
|
||||
storeUsers = (try? container.decodeIfPresent([V9StoreUser].self, forKey: .storeUsers)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// v9 景区账号实体,表示用户可进入的某个景区身份。
|
||||
struct V9ScenicUser: Decodable, Equatable {
|
||||
static let accountTypeValue = "scenic_user"
|
||||
|
||||
let accountType: String
|
||||
let id: Int
|
||||
let userId: Int
|
||||
let scenicUserId: Int
|
||||
let ssUserId: Int
|
||||
let username: String
|
||||
let realName: String
|
||||
let nickname: String
|
||||
let phone: String
|
||||
let scenicId: Int
|
||||
let scenicName: String
|
||||
let isCurrent: Bool
|
||||
|
||||
/// set-user 使用的业务账号 ID,按后端可用字段优先级兜底。
|
||||
var businessUserId: Int {
|
||||
[ssUserId, scenicUserId, userId, id].first { $0 > 0 } ?? 0
|
||||
}
|
||||
|
||||
/// 用于界面展示的景区账号名称。
|
||||
var displayName: String {
|
||||
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username
|
||||
}
|
||||
|
||||
/// 转换为账号选择页使用的统一账号实体。
|
||||
func toAccountSwitchAccount() -> AccountSwitchAccount {
|
||||
AccountSwitchAccount(
|
||||
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
|
||||
businessUserId: businessUserId,
|
||||
title: scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? "景区账号",
|
||||
subtitle: [realName.nonEmpty, nickname.nonEmpty]
|
||||
.compactMap(\.self)
|
||||
.joined(separator: " · "),
|
||||
phone: phone,
|
||||
avatar: "",
|
||||
scenicName: scenicName,
|
||||
storeId: nil,
|
||||
storeName: "",
|
||||
scenicId: scenicId > 0 ? scenicId : nil,
|
||||
isCurrent: isCurrent
|
||||
)
|
||||
}
|
||||
|
||||
/// 景区账号响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accountType = "account_type"
|
||||
case id
|
||||
case userId = "user_id"
|
||||
case scenicUserId = "scenic_user_id"
|
||||
case ssUserId = "ss_user_id"
|
||||
case username
|
||||
case realName = "real_name"
|
||||
case nickname
|
||||
case phone
|
||||
case scenicId = "scenic_id"
|
||||
case scenicName = "scenic_name"
|
||||
case isCurrent = "is_current"
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容 ID 字段类型不稳定和字段缺失。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
accountType = try container.decodeLossyString(forKey: .accountType).nonEmpty ?? Self.accountTypeValue
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||
scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0
|
||||
ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0
|
||||
username = try container.decodeLossyString(forKey: .username)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
nickname = try container.decodeLossyString(forKey: .nickname)
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
|
||||
scenicName = try container.decodeLossyString(forKey: .scenicName)
|
||||
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// v9 门店账号实体,表示用户可进入的某个门店身份。
|
||||
struct V9StoreUser: Decodable, Equatable {
|
||||
static let accountTypeValue = "store_user"
|
||||
|
||||
let accountType: String
|
||||
let id: Int
|
||||
let userId: Int
|
||||
let storeUserId: Int
|
||||
let username: String
|
||||
let userName: String
|
||||
let realName: String
|
||||
let phone: String
|
||||
let avatar: String
|
||||
let scenicId: Int
|
||||
let scenicName: String
|
||||
let storeId: Int
|
||||
let storeName: String
|
||||
let isCurrent: Bool
|
||||
|
||||
/// set-user 使用的业务账号 ID,按后端可用字段优先级兜底。
|
||||
var businessUserId: Int {
|
||||
[storeUserId, userId, id].first { $0 > 0 } ?? 0
|
||||
}
|
||||
|
||||
/// 用于界面展示的门店账号名称。
|
||||
var displayName: String {
|
||||
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username
|
||||
}
|
||||
|
||||
/// 转换为账号选择页使用的统一账号实体。
|
||||
func toAccountSwitchAccount() -> AccountSwitchAccount {
|
||||
AccountSwitchAccount(
|
||||
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
|
||||
businessUserId: businessUserId,
|
||||
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号",
|
||||
subtitle: [scenicName.nonEmpty, realName.nonEmpty ?? userName.nonEmpty]
|
||||
.compactMap(\.self)
|
||||
.joined(separator: " · "),
|
||||
phone: phone,
|
||||
avatar: avatar,
|
||||
scenicName: scenicName,
|
||||
storeId: storeId > 0 ? storeId : nil,
|
||||
storeName: storeName,
|
||||
scenicId: scenicId > 0 ? scenicId : nil,
|
||||
isCurrent: isCurrent
|
||||
)
|
||||
}
|
||||
|
||||
/// 门店账号响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accountType = "account_type"
|
||||
case id
|
||||
case userId = "user_id"
|
||||
case storeUserId = "store_user_id"
|
||||
case username
|
||||
case userName = "user_name"
|
||||
case realName = "real_name"
|
||||
case phone
|
||||
case avatar
|
||||
case scenicId = "scenic_id"
|
||||
case scenicName = "scenic_name"
|
||||
case storeId = "store_id"
|
||||
case storeName = "store_name"
|
||||
case isCurrent = "is_current"
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容 ID 字段类型不稳定和字段缺失。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
accountType = try container.decodeLossyString(forKey: .accountType).nonEmpty ?? Self.accountTypeValue
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||
storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0
|
||||
username = try container.decodeLossyString(forKey: .username)
|
||||
userName = try container.decodeLossyString(forKey: .userName)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
avatar = try container.decodeLossyString(forKey: .avatar)
|
||||
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
|
||||
scenicName = try container.decodeLossyString(forKey: .scenicName)
|
||||
storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0
|
||||
storeName = try container.decodeLossyString(forKey: .storeName)
|
||||
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 Bool、数字或布尔字符串宽松解码为布尔值。
|
||||
func decodeLossyBool(forKey key: Key) throws -> Bool? {
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value != 0
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if ["1", "true", "yes"].contains(normalized) { return true }
|
||||
if ["0", "false", "no"].contains(normalized) { return false }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? {
|
||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
213
suixinkan/Features/Auth/ViewModels/LoginViewModel.swift
Normal file
213
suixinkan/Features/Auth/ViewModels/LoginViewModel.swift
Normal file
@ -0,0 +1,213 @@
|
||||
//
|
||||
// LoginViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 登录输入焦点实体,标识当前需要聚焦的输入框。
|
||||
enum LoginField: Hashable {
|
||||
case username
|
||||
case password
|
||||
}
|
||||
|
||||
/// 登录表单校验错误实体,负责提供用户提示和焦点位置。
|
||||
enum LoginValidationError: Equatable {
|
||||
case invalidPhone
|
||||
case emptyPassword
|
||||
case privacyUnchecked
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .invalidPhone:
|
||||
"请输入有效的手机号码"
|
||||
case .emptyPassword:
|
||||
"请输入密码"
|
||||
case .privacyUnchecked:
|
||||
"请先阅读并同意相关协议"
|
||||
}
|
||||
}
|
||||
|
||||
var focusField: LoginField? {
|
||||
switch self {
|
||||
case .invalidPhone:
|
||||
.username
|
||||
case .emptyPassword:
|
||||
.password
|
||||
case .privacyUnchecked:
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录结果实体,区分已完成登录和需要用户选择账号两种情况。
|
||||
enum LoginResolution {
|
||||
case completed(V9AuthResponse)
|
||||
case needsAccountSelection(AccountSelectionPayload)
|
||||
}
|
||||
|
||||
/// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。
|
||||
enum LoginFlowError: LocalizedError {
|
||||
case missingToken
|
||||
case noAvailableAccount
|
||||
case invalidAccount
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingToken:
|
||||
"登录响应缺少 token"
|
||||
case .noAvailableAccount:
|
||||
"当前账号没有可用的景区账号或门店账号,请联系管理员"
|
||||
case .invalidAccount:
|
||||
"账号信息异常,请重新选择账号"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 登录页 ViewModel,负责表单状态、登录请求和多账号选择流程。
|
||||
final class LoginViewModel {
|
||||
var username = "18651857230"
|
||||
var password = "zhifly666"
|
||||
var privacyChecked = false
|
||||
var isLoading = false
|
||||
var isSelectingAccount = false
|
||||
var showsPassword = false
|
||||
var showsAgreementSheet = false
|
||||
var pendingAccountSelection: AccountSelectionPayload?
|
||||
|
||||
var canSubmit: Bool {
|
||||
isValidPhone && !trimmedPassword.isEmpty
|
||||
}
|
||||
|
||||
var trimmedPassword: String {
|
||||
password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
var normalizedUsername: String {
|
||||
normalizedPhoneNumber(username)
|
||||
}
|
||||
|
||||
var isValidPhone: Bool {
|
||||
normalizedUsername.count == 11 && normalizedUsername.first == "1"
|
||||
}
|
||||
|
||||
/// 应用本地登录偏好,只恢复手机号和协议状态,不恢复密码。
|
||||
func applyPreferences(_ preferences: LoginPreferences) {
|
||||
if username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let lastUsername = preferences.lastUsername {
|
||||
username = lastUsername
|
||||
}
|
||||
privacyChecked = preferences.privacyAgreementAccepted
|
||||
}
|
||||
|
||||
/// 校验登录表单,并返回第一个需要处理的错误。
|
||||
func validateForLogin() -> LoginValidationError? {
|
||||
guard isValidPhone else {
|
||||
return .invalidPhone
|
||||
}
|
||||
guard !trimmedPassword.isEmpty else {
|
||||
return .emptyPassword
|
||||
}
|
||||
guard privacyChecked else {
|
||||
return .privacyUnchecked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 用户同意协议后关闭协议确认弹窗。
|
||||
func acceptAgreement() {
|
||||
privacyChecked = true
|
||||
showsAgreementSheet = false
|
||||
}
|
||||
|
||||
/// 当输入包含 +86 前缀时,把手机号规范化为 11 位国内手机号。
|
||||
func normalizeUsernameCountryCodeIfNeeded() {
|
||||
let digits = username.filter(\.isNumber)
|
||||
let normalizedPhone = normalizedPhoneNumber(username)
|
||||
guard normalizedPhone != digits, normalizedPhone.count == 11 else { return }
|
||||
username = normalizedPhone
|
||||
}
|
||||
|
||||
/// 发起登录,并根据账号数量决定直接完成或进入账号选择。
|
||||
func login(authAPI: AuthAPI) async throws -> LoginResolution {
|
||||
guard !isLoading else {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
let response = try await authAPI.login(
|
||||
username: normalizedUsername,
|
||||
password: trimmedPassword
|
||||
)
|
||||
return try await resolveLoginResponse(response, authAPI: authAPI)
|
||||
}
|
||||
|
||||
/// 选择一个账号并调用 set-user 换取正式 token。
|
||||
func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse {
|
||||
guard let payload = pendingAccountSelection, payload.hasTempToken else {
|
||||
throw LoginFlowError.missingToken
|
||||
}
|
||||
guard account.businessUserId > 0 else {
|
||||
throw LoginFlowError.invalidAccount
|
||||
}
|
||||
guard !isSelectingAccount else {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
isSelectingAccount = true
|
||||
defer { isSelectingAccount = false }
|
||||
|
||||
let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: payload.tempToken)
|
||||
guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw LoginFlowError.missingToken
|
||||
}
|
||||
pendingAccountSelection = nil
|
||||
return response
|
||||
}
|
||||
|
||||
/// 清空待选账号信息,通常在用户取消账号选择时调用。
|
||||
func clearPendingAccountSelection() {
|
||||
pendingAccountSelection = nil
|
||||
}
|
||||
|
||||
/// 解析登录响应,单账号自动 set-user,多账号保留选择载荷。
|
||||
private func resolveLoginResponse(_ response: V9AuthResponse, authAPI: AuthAPI) async throws -> LoginResolution {
|
||||
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !token.isEmpty else {
|
||||
throw LoginFlowError.missingToken
|
||||
}
|
||||
|
||||
let accounts = response.accounts.filter { $0.businessUserId > 0 }
|
||||
guard !accounts.isEmpty else {
|
||||
throw LoginFlowError.noAvailableAccount
|
||||
}
|
||||
|
||||
if accounts.count == 1, let account = accounts.first {
|
||||
let finalResponse = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: token)
|
||||
guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw LoginFlowError.missingToken
|
||||
}
|
||||
return .completed(finalResponse)
|
||||
}
|
||||
|
||||
let payload = AccountSelectionPayload(tempToken: token, accounts: accounts)
|
||||
pendingAccountSelection = payload
|
||||
return .needsAccountSelection(payload)
|
||||
}
|
||||
|
||||
/// 过滤手机号中的非数字字符,并去掉 86 国家码前缀。
|
||||
private func normalizedPhoneNumber(_ value: String) -> String {
|
||||
var digits = value.filter(\.isNumber)
|
||||
if digits.hasPrefix("86"), digits.count == 13 {
|
||||
digits.removeFirst(2)
|
||||
}
|
||||
return digits
|
||||
}
|
||||
}
|
||||
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