Initial commit

This commit is contained in:
2026-06-22 11:28:01 +08:00
commit ace9c94359
84 changed files with 8899 additions and 0 deletions

View 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
)
}
}

View 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`,不落盘。

View 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 ""
}
/// IntDouble
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
}
}

View 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
}
}

View 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 }
)
}

View 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())
}