初始提交

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

View File

@ -0,0 +1,78 @@
//
// AccountContextAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import Observation
@MainActor
/// 便
protocol AccountContextServing {
///
func rolePermissions() async throws -> [RolePermissionResponse]
/// 访
func scenicListAll() async throws -> ScenicListAllResponse
/// 访
func storeAll() async throws -> ListPayload<StoreItem>
///
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem>
}
@MainActor
@Observable
/// API
final class AccountContextAPI: AccountContextServing {
@ObservationIgnored private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func rolePermissions() async throws -> [RolePermissionResponse] {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/role-permission"
)
)
}
/// 访
func scenicListAll() async throws -> ScenicListAllResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/scenic/list-all"
)
)
}
/// 访
func storeAll() async throws -> ListPayload<StoreItem> {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/store/all"
)
)
}
///
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem> {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/scenic-spot/list-all",
queryItems: [URLQueryItem(name: "scenic_id", value: "\(scenicId)")]
)
)
}
}

View File

@ -0,0 +1,36 @@
# Account 模块业务逻辑
## 模块职责
Account 模块负责同步账号进入业务页前必须具备的上下文数据,包括角色权限、景区、门店和当前景区下的景点/打卡点。
该模块只做读取和上下文装配,不负责登录 token 存储,也不负责页面导航。
## 数据边界
- 角色权限:由 `/api/yf-handset-app/role-permission` 返回,写入 `PermissionContext`
- 景区列表:由 `/api/yf-handset-app/photog/scenic/list-all` 返回,用于账号业务作用域。
- 门店列表:由 `/api/app/store/all` 返回,并通过 `scenic_id` 关联景区。
- 景点/打卡点:由 `/api/yf-handset-app/photog/scenic-spot/list-all` 按当前景区懒加载。
## 加载流程
登录成功或冷启动恢复时,`AccountContextLoader` 会并行请求用户资料和角色权限。权限成功后继续读取景区和门店:
1. 用户资料刷新 `AccountContext.profile`
2. 角色权限刷新 `PermissionContext`,并计算当前角色的权限 URI 集合。
3. 景区列表失败时,从角色权限里的景区数据去重兜底。
4. 门店列表失败时不阻断登录,只写入空门店列表。
5. 当前景区确定后,`ScenicSpotContext` 再按景区 ID 拉取景点/打卡点。
## 切换规则
- 切换角色时,当前景区优先保留在新角色可访问景区中,否则选择新角色第一个景区。
- 切换景区时,当前门店优先保留同景区门店,否则选择该景区第一个门店。
- 切换景区后,景点/打卡点列表会重新加载。
## 缓存规则
`AccountSnapshotStore` 只保存非敏感上下文快照,包括当前角色 ID、当前景区 ID、当前门店 ID 和基础账号展示信息。
景点/打卡点列表只保存在内存中,不长期落盘。后续业务需要离线缓存时,需要按账号类型、业务账号 ID 和景区 ID 做隔离。

View File

@ -0,0 +1,355 @@
//
// AccountContextModels.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// 访
struct RolePermissionResponse: Decodable, Equatable {
let role: RoleInfo
let scenic: [ScenicInfo]
}
///
struct RoleInfo: Decodable, Equatable, Identifiable {
let id: Int
let name: String
let notes: String?
let permission: [PermissionItem]
/// JSON
enum CodingKeys: String, CodingKey {
case id
case name
case notes
case permission
}
///
init(id: Int, name: String, notes: String? = nil, permission: [PermissionItem] = []) {
self.id = id
self.name = name
self.notes = notes
self.permission = permission
}
/// ID
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
name = try container.decodeLossyString(forKey: .name)
notes = try container.decodeIfPresent(String.self, forKey: .notes)
permission = try container.decodeIfPresent([PermissionItem].self, forKey: .permission) ?? []
}
}
///
struct PermissionItem: Decodable, Equatable, Identifiable {
let id: Int
let pid: Int
let name: String
let uri: String
let iconSrc: String?
let children: [PermissionItem]
/// JSON
enum CodingKeys: String, CodingKey {
case id
case pid
case name
case uri
case iconSrc = "icon_src"
case children
}
///
init(id: Int, pid: Int = 0, name: String, uri: String = "", iconSrc: String? = nil, children: [PermissionItem] = []) {
self.id = id
self.pid = pid
self.name = name
self.uri = uri
self.iconSrc = iconSrc
self.children = children
}
/// ID children
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
pid = try container.decodeLossyInt(forKey: .pid) ?? 0
name = try container.decodeLossyString(forKey: .name)
uri = try container.decodeLossyString(forKey: .uri)
iconSrc = try container.decodeIfPresent(String.self, forKey: .iconSrc)
children = try container.decodeIfPresent([PermissionItem].self, forKey: .children) ?? []
}
}
/// 访
struct ScenicInfo: Decodable, Equatable, Identifiable {
let id: Int
let name: String
let status: Int?
let location: ScenicLocationInfo?
let coverImg: String?
/// JSON
enum CodingKeys: String, CodingKey {
case id
case name
case status
case location
case coverImg = "cover_img"
}
///
init(id: Int, name: String, status: Int? = nil, location: ScenicLocationInfo? = nil, coverImg: String? = nil) {
self.id = id
self.name = name
self.status = status
self.location = location
self.coverImg = coverImg
}
/// location JSON
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
name = try container.decodeLossyString(forKey: .name)
status = try container.decodeLossyInt(forKey: .status)
coverImg = try container.decodeIfPresent(String.self, forKey: .coverImg)
if let value = try? container.decodeIfPresent(ScenicLocationInfo.self, forKey: .location) {
location = value
} else if let rawValue = try? container.decodeIfPresent(String.self, forKey: .location),
let data = rawValue.data(using: .utf8) {
location = try? JSONDecoder().decode(ScenicLocationInfo.self, from: data)
} else {
location = nil
}
}
}
///
struct ScenicLocationInfo: Decodable, Equatable {
let lng: Double
let lat: Double
let address: String
/// JSON
enum CodingKeys: String, CodingKey {
case lng
case lat
case address
}
///
init(lng: Double, lat: Double, address: String) {
self.lng = lng
self.lat = lat
self.address = address
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
lng = try container.decodeLossyDouble(forKey: .lng) ?? 0
lat = try container.decodeLossyDouble(forKey: .lat) ?? 0
address = try container.decodeLossyString(forKey: .address)
}
}
///
struct ScenicListAllResponse: Decodable, Equatable {
let total: Int
let list: [ScenicListItem]
/// JSON
enum CodingKeys: String, CodingKey {
case total
case list
}
///
init(total: Int, list: [ScenicListItem]) {
self.total = total
self.list = list
}
/// total
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([ScenicListItem].self, forKey: .list) ?? []
}
}
///
struct ScenicListItem: Decodable, Equatable, Identifiable {
let id: Int
let name: String
/// JSON
enum CodingKeys: String, CodingKey {
case id
case name
}
///
init(id: Int, name: String) {
self.id = id
self.name = name
}
/// ID
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
name = try container.decodeLossyString(forKey: .name)
}
}
///
struct StoreItem: Decodable, Equatable, Identifiable {
let id: Int
let scenicId: Int
let name: String
let address: String
let status: Int
let statusText: String
let tel: String?
let logo: String?
/// JSON
enum CodingKeys: String, CodingKey {
case id
case scenicId = "scenic_id"
case name
case address
case status
case statusText = "status_text"
case tel
case logo
}
///
init(
id: Int,
scenicId: Int,
name: String,
address: String = "",
status: Int = 0,
statusText: String = "",
tel: String? = nil,
logo: String? = nil
) {
self.id = id
self.scenicId = scenicId
self.name = name
self.address = address
self.status = status
self.statusText = statusText
self.tel = tel
self.logo = logo
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
name = try container.decodeLossyString(forKey: .name)
address = try container.decodeLossyString(forKey: .address)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusText = try container.decodeLossyString(forKey: .statusText)
tel = try container.decodeIfPresent(String.self, forKey: .tel)
logo = try container.decodeIfPresent(String.self, forKey: .logo)
}
}
///
struct ScenicSpotItem: Decodable, Equatable, Identifiable {
let id: Int
let name: String
let status: Int
let statusLabel: String
/// JSON
enum CodingKeys: String, CodingKey {
case id
case name
case status
case statusLabel = "status_label"
}
///
init(id: Int, name: String, status: Int = 0, statusLabel: String = "") {
self.id = id
self.name = name
self.status = status
self.statusLabel = statusLabel
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
name = try container.decodeLossyString(forKey: .name)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
}
}
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
}
/// DoubleInt
func decodeLossyDouble(forKey key: Key) throws -> Double? {
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return Double(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
return Double(value.trimmingCharacters(in: .whitespacesAndNewlines))
}
return nil
}
}

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

View File

@ -0,0 +1,36 @@
# Home 模块业务逻辑
## 模块职责
Home 模块负责登录后的首页工作台,包括当前景区展示、工作状态、位置上报入口、快捷操作、常用应用和全部功能入口。
首页菜单来自 `PermissionContext` 中的角色权限。当前模块只同步首页壳和入口路由,入口背后的业务子模块后续逐个迁移。
## 菜单生成
`HomeViewModel` 从当前角色的权限树递归提取菜单:
- 当前角色 ID 存在但找不到时清空菜单。
- 当前角色 ID 为空时使用第一个角色。
- URI 按旧工程顺序排序。
- 同义 URI 按 `HomeMenuRouter.menuAliasKey` 去重。
## 常用应用
`HomeCommonMenuStore` 使用 UserDefaults 保存常用应用 URI
- 首次无配置时写入默认常用应用。
- 读取时按当前角色权限过滤不可用 URI。
- 添加和移除时按同义 URI 去重。
## 路由规则
`HomeMenuRouter` 将权限 URI 分为四类:
- `tab`:切换到订单或数据 Tab。
- `destination`:进入已接入的本地页面,如个人信息、景区选择、全部功能。
- `unsupported`:已知 iOS 不支持的入口,进入占位说明。
- `placeholder`:未知或未迁移入口,记录诊断并进入占位说明。
`HomeView` 不创建自己的 `NavigationStack`,而是使用 Main Tab 注入的 `RouterPath` 进行页面跳转。
## 后续迁移
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。

View File

@ -0,0 +1,62 @@
//
// HomeMenuItem.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// URI
struct HomeMenuItem: Equatable, Identifiable {
let title: String
let uri: String
let iconSrc: String?
var id: String {
uri
}
}
///
enum HomeRoute: Hashable {
case profileSpace
case scenicSelection
case moreFunctions
case modulePlaceholder(uri: String, title: String)
}
/// URI Tab
enum HomeMenuResolvedRoute: Equatable {
case tab(AppTab)
case destination(HomeRoute)
case unsupported(uri: String, title: String, reason: String)
case placeholder(uri: String, title: String)
}
/// iOS URI
struct UnknownHomeRouteRecord: Codable, Equatable {
let uri: String
let title: String
let firstSeenAt: Date
let lastSeenAt: Date
let count: Int
}
///
struct HomePermissionRouteAuditEntry: Equatable {
let uri: String
let title: String
let route: HomeMenuResolvedRoute
}
///
struct HomePermissionRouteAudit: Equatable {
let routable: [HomePermissionRouteAuditEntry]
let unsupported: [HomePermissionRouteAuditEntry]
let unknown: [HomePermissionRouteAuditEntry]
var hasUnknownRoutes: Bool {
!unknown.isEmpty
}
}

View File

@ -0,0 +1,412 @@
//
// HomeMenuRouter.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import os
/// URI iOS
enum HomeMenuRouter {
private static let titleMap: [String: String] = [
"basic_info": "基本信息",
"space_settings": "空间设置",
"album_list": "相册管理",
"album_trailer": "相册预览上传",
"photographer_stats": "数据统计",
"photographer_orders": "订单管理",
"/scenic-order-manage": "景区订单",
"verification_order": "核销订单",
"wallet": "我的钱包",
"cloud_management": "相册云盘",
"cloud_storage_transit": "传输管理",
"asset_management": "素材管理",
"material_upload": "上传素材",
"task_management": "任务管理",
"task_management_editor": "任务管理",
"task_create": "发布任务",
"schedule_management": "日程管理",
"system_settings": "设置中心",
"message_center": "消息中心",
"checkin_points": "打卡点管理",
"sample_management": "样片管理",
"sample_upload": "上传样片",
"live_stream_management": "直播管理",
"live_album": "直播相册",
"scenicselection": "景区选择",
"scenicapplication": "景区申请",
"permission_apply": "权限申请",
"permission_apply_status": "权限申请状态",
"scenic_settlement": "景区结算",
"scenic_settlement_review": "结算审核",
"pm": "项目管理",
"pm_manager": "项目管理",
"project_edit": "项目编辑",
"location_report": "位置上报",
"registration_invitation": "注册邀请",
"store": "店铺管理",
"fly": "飞行管理",
"pilot_cert": "飞手认证",
"pilot_controller": "飞控",
"/scenic-queue": "排队管理",
"queue_management": "排队管理",
"operating-area": "运营区域",
"payment_collection": "立即收款",
"payment_qr": "收款码",
"payment_code": "收款码",
"deposit_order_detail": "押金订单详情",
"deposit_order": "押金订单详情",
"deposit_order_shooting_info": "押金拍摄信息",
"withdrawal_audit": "提现审核",
"location_report_history": "定位上报历史",
"photographer_invite": "邀请摄影师",
"invite_record": "邀请记录",
"more_functions": "更多功能"
]
/// URI
static func resolve(uri: String, title: String) -> HomeMenuResolvedRoute {
switch uri {
case "photographer_orders", "/scenic-order-manage":
return .tab(.orders)
case "verification_order":
return .tab(.orders)
case "photographer_stats":
return .tab(.statistics)
case "space_settings", "basic_info":
return .destination(.profileSpace)
case "scenicselection":
return .destination(.scenicSelection)
case "store", "more_functions":
return .destination(.moreFunctions)
case "fly", "pilot_controller":
return .unsupported(
uri: uri,
title: title.isEmpty ? self.title(for: uri) : title,
reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。"
)
case "wallet",
"message_center",
"/scenic-queue",
"queue_management",
"payment_collection",
"payment_qr",
"payment_code",
"deposit_order_detail",
"deposit_order",
"deposit_order_shooting_info",
"withdrawal_audit",
"task_management",
"task_management_editor",
"task_create",
"schedule_management",
"checkin_points",
"pm",
"pm_manager",
"project_edit",
"cloud_management",
"cloud_storage_transit",
"album_list",
"album_trailer",
"asset_management",
"material_upload",
"sample_management",
"sample_upload",
"live_stream_management",
"live_album",
"scenicapplication",
"permission_apply",
"permission_apply_status",
"scenic_settlement",
"scenic_settlement_review",
"operating-area",
"location_report_history",
"location_report",
"registration_invitation",
"photographer_invite",
"invite_record",
"system_settings",
"pilot_cert":
let resolvedTitle = title.isEmpty ? self.title(for: uri) : title
return .destination(.modulePlaceholder(uri: uri, title: resolvedTitle))
default:
return .placeholder(uri: uri, title: title.isEmpty ? self.title(for: uri) : title)
}
}
/// URI
static func title(for uri: String) -> String {
titleMap[uri] ?? uri
}
/// URI
static func canonicalURI(for uri: String, availableURIs: Set<String>) -> String {
if availableURIs.contains(uri) {
return uri
}
switch uri {
case "task_management":
return availableURIs.contains("task_management_editor") ? "task_management_editor" : uri
case "task_management_editor":
return availableURIs.contains("task_management") ? "task_management" : uri
case "registration_invitation":
return availableURIs.contains("photographer_invite") ? "photographer_invite" : uri
case "photographer_invite":
return availableURIs.contains("registration_invitation") ? "registration_invitation" : uri
case "pm":
return availableURIs.contains("pm_manager") ? "pm_manager" : uri
case "pm_manager":
return availableURIs.contains("pm") ? "pm" : uri
case "payment_code":
return availableURIs.contains("payment_qr") ? "payment_qr" : uri
default:
return uri
}
}
/// URI
static func menuAliasKey(for uri: String) -> String {
switch uri {
case "task_management", "task_management_editor":
return "task_management"
case "registration_invitation", "photographer_invite":
return "registration_invitation"
case "pm", "pm_manager", "project_edit":
return "pm"
case "payment_collection", "payment_qr", "payment_code":
return "payment_collection"
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
return "deposit_order"
case "photographer_orders", "/scenic-order-manage":
return "photographer_orders"
case "/scenic-queue", "queue_management":
return "queue_management"
default:
return uri
}
}
///
static func displayTitle(for uri: String, fallback: String) -> String {
switch uri {
case "registration_invitation", "photographer_invite":
return "注册邀请"
case "location_report":
return "位置上报"
case "pm", "pm_manager":
return "项目管理"
case "space_settings":
return "空间设置"
case "task_management", "task_management_editor":
return "任务管理"
default:
return fallback
}
}
}
/// URI
enum HomePermissionRouteAuditor {
/// URI
static func audit(permissions: [RolePermissionResponse], currentRoleId: Int?) -> HomePermissionRouteAudit {
let source: RolePermissionResponse?
if let currentRoleId {
source = permissions.first { $0.role.id == currentRoleId }
} else {
source = permissions.first
}
let entries = uniquePermissionItems(from: source?.role.permission ?? []).map { item in
let title = item.name.isEmpty ? HomeMenuRouter.title(for: item.uri) : item.name
return HomePermissionRouteAuditEntry(
uri: item.uri,
title: title,
route: HomeMenuRouter.resolve(uri: item.uri, title: title)
)
}
return HomePermissionRouteAudit(
routable: entries.filter { entry in
switch entry.route {
case .tab, .destination:
return true
case .unsupported, .placeholder:
return false
}
},
unsupported: entries.filter { entry in
if case .unsupported = entry.route { return true }
return false
},
unknown: entries.filter { entry in
if case .placeholder = entry.route { return true }
return false
}
)
}
/// Markdown
static func markdownReport(audit: HomePermissionRouteAudit, generatedAt: Date = Date()) -> String {
var lines = [
"# Home Permission Route Audit",
"",
"Generated: \(reportDateFormatter.string(from: generatedAt))",
"",
"Routable: \(audit.routable.count)",
"Unsupported: \(audit.unsupported.count)",
"Unknown: \(audit.unknown.count)",
""
]
if audit.unknown.isEmpty {
lines.append("No unknown permission routes.")
} else {
lines.append("## Unknown Routes")
lines.append("")
lines.append("| URI | Title |")
lines.append("| --- | --- |")
lines.append(contentsOf: audit.unknown.map { "| \(escape($0.uri)) | \(escape($0.title)) |" })
lines.append("")
lines.append("Action: map these URIs in `HomeMenuRouter`, confirm unsupported scope, or remove them before release.")
}
if !audit.unsupported.isEmpty {
lines.append("")
lines.append("## Known Unsupported Routes")
lines.append("")
lines.append("| URI | Title | Reason |")
lines.append("| --- | --- | --- |")
lines.append(contentsOf: audit.unsupported.map { entry in
let reason: String
if case let .unsupported(_, _, value) = entry.route {
reason = value
} else {
reason = ""
}
return "| \(escape(entry.uri)) | \(escape(entry.title)) | \(escape(reason)) |"
})
}
return lines.joined(separator: "\n")
}
private static var reportDateFormatter: ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}
/// URI URI
private static func uniquePermissionItems(from items: [PermissionItem]) -> [PermissionItem] {
var seen = Set<String>()
return flatten(items).filter { item in
guard !item.uri.isEmpty else { return false }
return seen.insert(HomeMenuRouter.menuAliasKey(for: item.uri)).inserted
}
}
///
private static func flatten(_ items: [PermissionItem]) -> [PermissionItem] {
items.flatMap { item in
[item] + flatten(item.children)
}
}
/// Markdown
private static func escape(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "|", with: "\\|")
.replacingOccurrences(of: "\n", with: " ")
}
}
/// URI
enum HomeRouteDiagnostics {
private static let logger = Logger(subsystem: "com.yuanzhixiang.suixinkan", category: "HomeRouting")
private static let defaultsKey = "home.routing.unknown.records"
private static let dateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
/// URI
static func recordUnknown(uri: String, title: String) {
logger.warning("Unknown home menu uri: \(uri, privacy: .public), title: \(title, privacy: .public)")
var records = unknownRoutes()
let now = Date()
if let index = records.firstIndex(where: { $0.uri == uri }) {
let current = records[index]
records[index] = UnknownHomeRouteRecord(
uri: current.uri,
title: title.isEmpty ? current.title : title,
firstSeenAt: current.firstSeenAt,
lastSeenAt: now,
count: current.count + 1
)
} else {
records.append(UnknownHomeRouteRecord(uri: uri, title: title, firstSeenAt: now, lastSeenAt: now, count: 1))
}
save(records)
}
///
static func unknownRoutes() -> [UnknownHomeRouteRecord] {
guard let data = UserDefaults.standard.data(forKey: defaultsKey),
let records = try? JSONDecoder().decode([UnknownHomeRouteRecord].self, from: data)
else { return [] }
return records
}
/// Markdown
static func unknownRouteReport(generatedAt: Date = Date()) -> String {
let records = unknownRoutes().sorted { lhs, rhs in
if lhs.count != rhs.count {
return lhs.count > rhs.count
}
return lhs.lastSeenAt > rhs.lastSeenAt
}
var lines = [
"# Home Route Diagnostics",
"",
"Generated: \(dateFormatter.string(from: generatedAt))",
""
]
guard !records.isEmpty else {
lines.append("No unknown home routes recorded.")
return lines.joined(separator: "\n")
}
lines.append("| URI | Title | Count | First Seen | Last Seen |")
lines.append("| --- | --- | ---: | --- | --- |")
lines.append(contentsOf: records.map { record in
"| \(escape(record.uri)) | \(escape(record.title)) | \(record.count) | \(dateFormatter.string(from: record.firstSeenAt)) | \(dateFormatter.string(from: record.lastSeenAt)) |"
})
lines.append("")
lines.append("Action: each URI above must be mapped in `HomeMenuRouter`, confirmed as unsupported scope, or explicitly accepted before release.")
return lines.joined(separator: "\n")
}
///
static func resetUnknownRoutes() {
UserDefaults.standard.removeObject(forKey: defaultsKey)
}
///
private static func save(_ records: [UnknownHomeRouteRecord]) {
guard let data = try? JSONEncoder().encode(records) else { return }
UserDefaults.standard.set(data, forKey: defaultsKey)
}
/// Markdown
private static func escape(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "|", with: "\\|")
.replacingOccurrences(of: "\n", with: " ")
}
}

View File

@ -0,0 +1,91 @@
//
// HomeCommonMenuStore.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// URI
struct HomeCommonMenuStore {
static let defaultStorageKey = "home.common.menu.uris"
static let defaultBaselineKey = "home.common.menu.android.baseline.v2"
private let defaults: UserDefaults
private let storageKey: String
private let baselineKey: String
/// UserDefaults
init(
defaults: UserDefaults = .standard,
storageKey: String = Self.defaultStorageKey,
baselineKey: String = Self.defaultBaselineKey
) {
self.defaults = defaults
self.storageKey = storageKey
self.baselineKey = baselineKey
}
/// URI使
func load(menuItems: [HomeMenuItem]) -> [String] {
let availableURIs = Set(menuItems.map(\.uri))
if !defaults.bool(forKey: baselineKey) {
let defaults = defaultCommonURIs(availableURIs: availableURIs)
save(defaults)
self.defaults.set(true, forKey: baselineKey)
return defaults
}
let saved = defaults.stringArray(forKey: storageKey) ?? []
let filtered = unique(saved.compactMap { uri -> String? in
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
return availableURIs.contains(resolved) ? resolved : nil
})
save(filtered)
return filtered
}
/// URI
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem]) -> [String] {
let availableURIs = Set(menuItems.map(\.uri))
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
guard availableURIs.contains(resolved) else { return current }
guard !current.contains(where: { HomeMenuRouter.menuAliasKey(for: $0) == HomeMenuRouter.menuAliasKey(for: resolved) }) else {
return current
}
let next = current + [resolved]
save(next)
return next
}
/// URI URI
func remove(_ uri: String, current: [String]) -> [String] {
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey }
save(next)
return next
}
/// URI
func save(_ uris: [String]) {
defaults.set(unique(uris), forKey: storageKey)
}
/// URI
private func defaultCommonURIs(availableURIs: Set<String>) -> [String] {
let preferred = ["registration_invitation", "location_report", "pm_manager", "pm"]
return unique(preferred.compactMap { uri in
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
return availableURIs.contains(resolved) ? resolved : nil
})
}
/// URI URI
private func unique(_ uris: [String]) -> [String] {
var seen = Set<String>()
return uris.filter { uri in
seen.insert(HomeMenuRouter.menuAliasKey(for: uri)).inserted
}
}
}

View File

@ -0,0 +1,116 @@
//
// HomeViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import Observation
@MainActor
@Observable
/// ViewModel
final class HomeViewModel {
private(set) var menuItems: [HomeMenuItem] = []
/// Android
private let preferredOrder: [String] = [
"space_settings",
"album_list",
"album_trailer",
"wallet",
"cloud_management",
"cloud_storage_transit",
"asset_management",
"material_upload",
"task_management",
"schedule_management",
"system_settings",
"message_center",
"checkin_points",
"sample_management",
"sample_upload",
"live_stream_management",
"scenicselection",
"scenicapplication",
"permission_apply",
"permission_apply_status",
"scenic_settlement",
"scenic_settlement_review",
"verification_order",
"live_album",
"pm",
"location_report",
"registration_invitation",
"store",
"fly",
"pilot_cert",
"task_management_editor",
"deposit_order_detail",
"deposit_order",
"pm_manager",
"pilot_controller",
"/scenic-queue",
"queue_management",
"operating-area",
"/scenic-order-manage",
"photographer_orders"
]
/// URI
func buildMenus(from permissions: [RolePermissionResponse], currentRoleId: Int?) {
let source: RolePermissionResponse?
if let currentRoleId {
source = permissions.first { $0.role.id == currentRoleId }
} else {
source = permissions.first
}
guard let source else {
menuItems = []
return
}
let permissionItems = flatten(source.role.permission)
var seen = Set<String>()
var deduplicated: [PermissionItem] = []
for item in permissionItems where !item.uri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if seen.insert(HomeMenuRouter.menuAliasKey(for: item.uri)).inserted {
deduplicated.append(item)
}
}
let preferredIndexMap = Dictionary(uniqueKeysWithValues: preferredOrder.enumerated().map { ($1, $0) })
let sortedItems = deduplicated.sorted { lhs, rhs in
let lhsRank = preferredIndexMap[lhs.uri] ?? Int.max
let rhsRank = preferredIndexMap[rhs.uri] ?? Int.max
if lhsRank != rhsRank {
return lhsRank < rhsRank
}
let lhsIndex = deduplicated.firstIndex(where: { $0.id == lhs.id }) ?? Int.max
let rhsIndex = deduplicated.firstIndex(where: { $0.id == rhs.id }) ?? Int.max
return lhsIndex < rhsIndex
}
menuItems = sortedItems.map { item in
HomeMenuItem(
title: item.name.isEmpty ? HomeMenuRouter.title(for: item.uri) : item.name,
uri: item.uri,
iconSrc: item.iconSrc
)
}
}
/// URI
func title(for uri: String) -> String {
HomeMenuRouter.title(for: uri)
}
///
private func flatten(_ items: [PermissionItem]) -> [PermissionItem] {
items.flatMap { item in
[item] + flatten(item.children)
}
}
}

View File

@ -0,0 +1,87 @@
//
// HomeIconCatalog.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// URI
enum HomeIconCatalog {
/// URI SF Symbols
static func iconName(for uri: String) -> String {
switch uri {
case "space_settings":
"person.crop.square.fill"
case "album_list":
"photo.stack"
case "album_trailer":
"square.stack.3d.up.fill"
case "wallet":
"creditcard.fill"
case "cloud_management":
"icloud.fill"
case "cloud_storage_transit":
"arrow.left.arrow.right.circle.fill"
case "asset_management", "/scenic-order-manage":
"photo.on.rectangle.angled"
case "material_upload":
"square.and.arrow.up"
case "task_management", "task_management_editor":
"checklist"
case "schedule_management":
"calendar"
case "system_settings":
"gearshape.fill"
case "message_center":
"bell.fill"
case "checkin_points":
"mappin.and.ellipse"
case "sample_management":
"point.3.connected.trianglepath.dotted"
case "sample_upload":
"square.and.arrow.up.on.square"
case "live_stream_management":
"dot.radiowaves.left.and.right"
case "verification_order":
"checkmark.seal.fill"
case "live_album":
"play.rectangle.on.rectangle.fill"
case "pm", "pm_manager", "project_edit":
"circle.grid.2x2.fill"
case "location_report", "location_report_history":
"mappin.circle.fill"
case "registration_invitation", "photographer_invite":
"envelope.open.fill"
case "store":
"storefront.fill"
case "fly", "pilot_cert", "pilot_controller":
"paperplane.fill"
case "/scenic-queue", "queue_management":
"person.3.fill"
case "operating-area":
"map.fill"
case "scenicselection":
"location.magnifyingglass"
case "scenicapplication":
"doc.badge.plus"
case "permission_apply", "permission_apply_status":
"person.badge.key"
case "scenic_settlement", "scenic_settlement_review":
"checklist"
case "payment_collection", "payment_qr", "payment_code":
"qrcode"
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
"doc.text.magnifyingglass"
case "withdrawal_audit":
"banknote.fill"
case "invite_record":
"list.bullet.rectangle.fill"
case "more_functions":
"ellipsis"
default:
"square.grid.2x2.fill"
}
}
}

View File

@ -0,0 +1,213 @@
//
// HomeMoreFunctionsView.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import SwiftUI
///
struct HomeMoreFunctionsView: View {
@Environment(PermissionContext.self) private var permissionContext
@Environment(AppRouter.self) private var appRouter
@Environment(RouterPath.self) private var router
@Environment(\.dismiss) private var dismiss
@State private var viewModel = HomeViewModel()
@State private var commonUris: [String] = []
private let commonMenuStore = HomeCommonMenuStore()
private var commonItems: [HomeMenuItem] {
commonUris.compactMap(menuItem(for:))
}
private var moreItems: [HomeMenuItem] {
viewModel.menuItems.filter { item in
!isCommonURI(item.uri)
}
}
var body: some View {
VStack(spacing: 0) {
topBar
ScrollView {
VStack(alignment: .leading, spacing: 26) {
section(title: "常用应用", items: commonItems, isCommon: true)
section(title: "更多功能", items: moreItems, isCommon: false)
}
.padding(.horizontal, AppMetrics.Spacing.mediumLarge)
.padding(.top, AppMetrics.Spacing.xxLarge + 1)
.padding(.bottom, AppMetrics.Spacing.xxLarge)
}
.background(Color(hex: 0xF5F5F5))
}
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
.toolbar(.hidden, for: .navigationBar)
.task {
rebuildMenus()
}
.onChange(of: permissionContext.currentRole?.id) { _, _ in
rebuildMenus()
}
.onChange(of: permissionContext.rolePermissions.count) { _, _ in
rebuildMenus()
}
}
private var topBar: some View {
ZStack {
Text("全部功能")
.font(.system(size: AppMetrics.FontSize.title2 + 1, weight: .semibold))
.foregroundStyle(Color(hex: 0x2C2C2C))
.frame(maxWidth: .infinity)
HStack {
Button {
dismiss()
} label: {
Image(systemName: "chevron.left")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(Color(hex: 0x111827))
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
}
.buttonStyle(.plain)
Spacer()
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
}
.frame(height: 62)
.background(.white)
.overlay(alignment: .bottom) {
Rectangle()
.fill(Color.black.opacity(0.06))
.frame(height: 0.5)
}
}
private func section(title: String, items: [HomeMenuItem], isCommon: Bool) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
Text(title)
.font(.system(size: AppMetrics.FontSize.title, weight: .bold))
.foregroundStyle(Color(hex: 0x2C2C2C))
appGrid(items: items, isCommon: isCommon)
}
}
private func appGrid(items: [HomeMenuItem], isCommon: Bool) -> some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 14), count: 3), spacing: AppMetrics.Spacing.mediumLarge) {
ForEach(items, id: \.uri) { item in
ZStack(alignment: .topTrailing) {
Button {
openMenu(item)
} label: {
VStack(spacing: AppMetrics.Spacing.mediumLarge + 1) {
menuIconView(for: item)
.frame(width: 34, height: 34)
Text(item.title)
.font(.system(size: AppMetrics.FontSize.callout))
.foregroundStyle(Color(hex: 0x252525))
.lineLimit(1)
.minimumScaleFactor(0.78)
}
.frame(maxWidth: .infinity)
.frame(height: 112)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
Button {
if isCommon {
commonUris = commonMenuStore.remove(item.uri, current: commonUris)
} else {
commonUris = commonMenuStore.add(item.uri, current: commonUris, menuItems: viewModel.menuItems)
}
} label: {
Image(systemName: isCommon ? "minus.circle.fill" : "plus.circle.fill")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(isCommon ? Color(hex: 0xFF1111) : AppDesign.primary)
.background(Color.white, in: Circle())
}
.buttonStyle(.plain)
.offset(x: 9, y: -9)
}
.frame(maxWidth: .infinity)
.frame(height: 112)
}
}
}
@ViewBuilder
private func menuIconView(for item: HomeMenuItem) -> some View {
if let src = item.iconSrc,
let url = URL(string: src),
!src.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFit()
default:
fallbackMenuIcon(for: item.uri)
}
}
} else {
fallbackMenuIcon(for: item.uri)
}
}
private func fallbackMenuIcon(for uri: String) -> some View {
Image(systemName: HomeIconCatalog.iconName(for: uri))
.resizable()
.scaledToFit()
.foregroundStyle(AppDesign.primary)
.frame(width: 34, height: 34)
}
private func menuItem(for uri: String) -> HomeMenuItem? {
let availableURIs = Set(viewModel.menuItems.map(\.uri))
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
guard let existing = viewModel.menuItems.first(where: { $0.uri == resolvedUri }) else {
return nil
}
return HomeMenuItem(
title: HomeMenuRouter.displayTitle(for: resolvedUri, fallback: existing.title),
uri: resolvedUri,
iconSrc: existing.iconSrc
)
}
private func isCommonURI(_ uri: String) -> Bool {
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
return commonUris.contains { HomeMenuRouter.menuAliasKey(for: $0) == aliasKey }
}
private func openMenu(_ item: HomeMenuItem) {
switch HomeMenuRouter.resolve(uri: item.uri, title: item.title) {
case .tab(let tab):
appRouter.select(tab)
case .destination(let route):
if route == .moreFunctions {
return
}
router.navigate(to: .home(route))
case .unsupported(let uri, let title, _):
router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title)))
case .placeholder(let uri, let title):
HomeRouteDiagnostics.recordUnknown(uri: uri, title: title)
router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title)))
}
}
private func rebuildMenus() {
viewModel.buildMenus(
from: permissionContext.rolePermissions,
currentRoleId: permissionContext.currentRole?.id
)
commonUris = commonMenuStore.load(menuItems: viewModel.menuItems)
}
}

View File

@ -0,0 +1,82 @@
//
// HomeSupportViews.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import SwiftUI
extension HomeRoute {
///
@ViewBuilder
var destinationView: some View {
switch self {
case .profileSpace:
ProfileView()
case .scenicSelection:
HomeScenicSelectionView()
case .moreFunctions:
HomeMoreFunctionsView()
case let .modulePlaceholder(uri, title):
HomeMigrationModuleView(title: title, uri: uri)
}
}
}
///
struct HomeMigrationModuleView: View {
let title: String
let uri: String
var body: some View {
VStack(spacing: AppMetrics.Spacing.medium) {
Image(systemName: "square.grid.2x2")
.font(.system(size: 44, weight: .semibold))
.foregroundStyle(AppDesign.primary)
Text(title)
.font(.system(size: AppMetrics.FontSize.title2, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text(uri)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(2)
.multilineTextAlignment(.center)
.padding(.horizontal, AppMetrics.Spacing.large)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: 0xF5F7FA))
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
}
}
///
struct HomeScenicSelectionView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(\.dismiss) private var dismiss
var body: some View {
List(accountContext.scenicScopes) { scenic in
Button {
accountContext.selectScenic(id: scenic.id)
dismiss()
} label: {
HStack {
Text(scenic.name)
.foregroundStyle(AppDesign.textPrimary)
Spacer()
if accountContext.currentScenic?.id == scenic.id {
Image(systemName: "checkmark")
.font(.system(size: AppMetrics.ControlSize.smallIcon, weight: .semibold))
.foregroundStyle(AppDesign.primary)
}
}
}
}
.navigationTitle("景区选择")
.navigationBarTitleDisplayMode(.inline)
}
}

View File

@ -0,0 +1,443 @@
//
// HomeView.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Combine
import SwiftUI
///
struct HomeView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(PermissionContext.self) private var permissionContext
@Environment(AppRouter.self) private var appRouter
@Environment(RouterPath.self) private var router
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var viewModel = HomeViewModel()
@State private var commonUris: [String] = []
@State private var isOnline = false
@State private var reminderMinutes = 0
@State private var secondsUntilReport = 0
@State private var showOnlineDialog = false
@State private var showReminderDialog = false
@State private var showReportSuccess = false
private let commonMenuStore = HomeCommonMenuStore()
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private let minimalTopRoleIds: Set<Int> = [46, 47, 52, 53, 54]
private var contentMaxWidth: CGFloat {
horizontalSizeClass == .regular ? 760 : .infinity
}
private var currentRoleId: Int? {
permissionContext.currentRole?.id
}
private var isStoreManager: Bool {
currentRoleId == 46
}
private var shouldShowWorkStatus: Bool {
guard let currentRoleId else { return true }
return !minimalTopRoleIds.contains(currentRoleId)
}
private var currentScenicName: String {
accountContext.currentScenic?.name ?? "请选择景区"
}
private var countdownDisplay: String {
let hours = secondsUntilReport / 3_600
let minutes = (secondsUntilReport % 3_600) / 60
let seconds = secondsUntilReport % 60
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
}
private var reminderText: String {
reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟"
}
private var tileHeight: CGFloat {
horizontalSizeClass == .regular ? 118 : 102
}
private var displayMenuItems: [HomeMenuItem] {
let selected = commonUris.compactMap(menuItemForHomeEntry(uri:))
let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected
return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
}
var body: some View {
VStack(spacing: 0) {
topBar
ScrollView {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
if shouldShowWorkStatus {
statusCard
locationReportCard
quickActionsRow
}
if isStoreManager, let store = accountContext.currentStore {
storeCard(store)
.padding(.bottom, AppMetrics.Spacing.xSmall)
}
Text("常用应用")
.font(.system(size: AppMetrics.FontSize.body, weight: .bold))
.foregroundStyle(Color(hex: 0x333333))
.padding(.top, shouldShowWorkStatus ? AppMetrics.Spacing.xxSmall : AppMetrics.Spacing.xSmall)
.padding(.bottom, AppMetrics.Spacing.xxSmall)
appGrid
}
.frame(maxWidth: contentMaxWidth, alignment: .topLeading)
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.top, AppMetrics.Spacing.xxLarge)
.padding(.bottom, AppMetrics.Spacing.pageVertical)
.frame(maxWidth: .infinity)
}
.background(Color(hex: 0xF5F5F5))
}
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
.toolbar(.hidden, for: .navigationBar)
.task {
rebuildMenusFromCurrentContext()
}
.onReceive(timer) { _ in
guard isOnline, secondsUntilReport > 0 else { return }
secondsUntilReport -= 1
}
.onChange(of: permissionContext.currentRole?.id) { _, _ in
rebuildMenusFromCurrentContext()
}
.onChange(of: permissionContext.rolePermissions.count) { _, _ in
rebuildMenusFromCurrentContext()
}
.alert("切换在线状态", isPresented: $showOnlineDialog) {
Button("取消", role: .cancel) {}
Button("确定") {
isOnline.toggle()
if isOnline {
secondsUntilReport = 7_200
}
}
} message: {
Text(isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。")
}
.confirmationDialog("提前提醒时间", isPresented: $showReminderDialog, titleVisibility: .visible) {
ForEach([0, 5, 10, 15, 30], id: \.self) { minute in
Button(minute == 0 ? "不提醒" : "\(minute)分钟") {
reminderMinutes = minute
}
}
Button("取消", role: .cancel) {}
}
.alert("上报成功", isPresented: $showReportSuccess) {
Button("知道了", role: .cancel) {}
} message: {
Text("上报已成功,距离下次上报时间 \(countdownDisplay)")
}
}
private var topBar: some View {
HStack {
Button {
router.navigate(to: .home(.scenicSelection))
} label: {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Text(currentScenicName)
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
.foregroundStyle(Color(hex: 0x333333))
.lineLimit(1)
.minimumScaleFactor(0.8)
Image(systemName: "chevron.down")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .bold))
.foregroundStyle(.black)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.frame(height: 78)
.background(.white)
}
private var statusCard: some View {
HStack(spacing: AppMetrics.Spacing.small) {
Button {
showOnlineDialog = true
} label: {
Text(isOnline ? "在线" : "离线")
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(isOnline ? Color(hex: 0xF0FDF4) : Color(hex: 0x7B8EAA))
.padding(.horizontal, AppMetrics.Spacing.small)
.padding(.vertical, AppMetrics.Spacing.xxSmall + 2)
.background(isOnline ? Color(hex: 0x22C55E) : Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 4))
}
.buttonStyle(.plain)
Spacer(minLength: AppMetrics.Spacing.xxSmall)
Label {
Text(countdownDisplay)
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
} icon: {
Image(systemName: "clock")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
}
.foregroundStyle(AppDesign.primary)
.lineLimit(1)
.minimumScaleFactor(0.85)
Spacer(minLength: AppMetrics.Spacing.xxSmall)
Button {
showReminderDialog = true
} label: {
Label {
Text(reminderText)
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
} icon: {
Image(systemName: "bell.fill")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
}
.foregroundStyle(AppDesign.primary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.buttonStyle(.plain)
}
.padding(15)
.frame(minHeight: 84)
.frame(maxWidth: .infinity)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
private var locationReportCard: some View {
HStack(spacing: AppMetrics.Spacing.medium) {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: "arrow.up.square.fill")
.font(.system(size: 25, weight: .bold))
.foregroundStyle(AppDesign.primary)
Text("立即上报")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(Color(hex: 0x333333))
}
Text("您已进入打卡范围")
.font(.system(size: 15))
.foregroundStyle(Color(hex: 0x999999))
}
Spacer()
Button {
secondsUntilReport = 7_200
showReportSuccess = true
} label: {
Image(systemName: "hand.tap.fill")
.font(.system(size: 38, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 92, height: 92)
.background(
LinearGradient(
colors: [Color(hex: 0x0073FF), Color(hex: 0x5CA8FF)],
startPoint: .top,
endPoint: .bottom
),
in: Circle()
)
}
.buttonStyle(.plain)
.accessibilityLabel("上报位置")
}
.padding(15)
.frame(minHeight: 132)
.frame(maxWidth: .infinity)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
private var quickActionsRow: some View {
HStack(spacing: AppMetrics.Spacing.small) {
quickAction(icon: "qrcode", title: "立即收款") {
openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"))
}
quickAction(icon: "checklist.checked", title: "提交任务") {
openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"))
}
quickAction(icon: isOnline ? "wifi" : "wifi.slash", title: isOnline ? "在线" : "离线", active: isOnline) {
showOnlineDialog = true
}
}
}
private func quickAction(icon: String, title: String, active: Bool = false, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: icon)
.font(.system(size: 30, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(height: 34)
Text(title)
.font(.system(size: AppMetrics.FontSize.body))
.foregroundStyle(Color.black)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.frame(height: tileHeight)
.background(active ? Color(hex: 0xE3F2FD) : .white, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
private func storeCard(_ store: BusinessScope) -> some View {
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text(store.name)
.font(.system(size: AppMetrics.FontSize.title2, weight: .bold))
.foregroundStyle(.black)
.lineLimit(1)
if let scenic = accountContext.currentScenic {
Text(scenic.name)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(Color(hex: 0x7B8EAA))
.lineLimit(2)
}
}
Spacer()
Text("营业中")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(Color(hex: 0x22C55E))
.padding(.horizontal, AppMetrics.Spacing.xSmall + 2)
.padding(.vertical, AppMetrics.Spacing.xxxSmall)
.background(Color(hex: 0xF0FDF4), in: RoundedRectangle(cornerRadius: 4))
}
.padding(AppMetrics.Spacing.medium)
.frame(maxWidth: .infinity)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
private var appGrid: some View {
LazyVGrid(columns: menuColumns, spacing: 15) {
ForEach(displayMenuItems) { item in
Button {
openMenu(item)
} label: {
VStack(spacing: AppMetrics.Spacing.small) {
menuIconView(for: item)
.frame(width: 30, height: 30)
Text(item.title)
.font(.system(size: AppMetrics.FontSize.body))
.foregroundStyle(Color(hex: 0x4B5563))
.lineLimit(1)
.minimumScaleFactor(0.75)
}
.frame(maxWidth: .infinity)
.frame(height: tileHeight)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
}
private var menuColumns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 15), count: 3)
}
@ViewBuilder
private func menuIconView(for item: HomeMenuItem) -> some View {
if let src = item.iconSrc,
let url = URL(string: src),
!src.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFit()
default:
fallbackMenuIcon(for: item.uri)
}
}
} else {
fallbackMenuIcon(for: item.uri)
}
}
private func fallbackMenuIcon(for uri: String) -> some View {
Image(systemName: iconName(for: uri))
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(width: 30, height: 30)
}
private func iconName(for uri: String) -> String {
HomeIconCatalog.iconName(for: uri)
}
private func menuItemForHomeEntry(uri: String) -> HomeMenuItem? {
let availableURIs = Set(viewModel.menuItems.map(\.uri))
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
guard let existing = viewModel.menuItems.first(where: { $0.uri == resolvedUri }) else {
return nil
}
return HomeMenuItem(
title: HomeMenuRouter.displayTitle(for: resolvedUri, fallback: existing.title),
uri: resolvedUri,
iconSrc: existing.iconSrc
)
}
private func openMenu(_ item: HomeMenuItem) {
openRoute(HomeMenuRouter.resolve(uri: item.uri, title: item.title))
}
private func openRoute(_ route: HomeMenuResolvedRoute) {
switch route {
case .tab(let tab):
appRouter.select(tab)
case .destination(let route):
router.navigate(to: .home(route))
case .unsupported(let uri, let title, _):
router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title)))
case .placeholder(let uri, let title):
HomeRouteDiagnostics.recordUnknown(uri: uri, title: title)
router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title)))
}
}
private func rebuildMenusFromCurrentContext() {
viewModel.buildMenus(
from: permissionContext.rolePermissions,
currentRoleId: permissionContext.currentRole?.id
)
commonUris = commonMenuStore.load(menuItems: viewModel.menuItems)
}
}
#Preview {
NavigationStack {
HomeView()
.environment(AccountContext())
.environment(PermissionContext())
.environment(AppRouter())
.environment(RouterPath())
}
}

View File

@ -0,0 +1,40 @@
# Main 模块业务逻辑
## 模块职责
Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口。
主界面由 `MainTabsView` 承载:
- 使用 `TabView` 展示首页、订单、数据、我的四个 Tab。
- 每个 Tab 内部都包裹独立的 `NavigationStack`
- 每个 Tab 的导航路径由 `AppRouter` 单独保存。
## Tab 结构
当前 Tab 来自 `AppTab`
- `home`:首页
- `orders`:订单
- `statistics`:数据
- `profile`:我的
`HomeRootView` 已接入真实的 `HomeView``ProfileRootView` 已接入真实的 `ProfileView`。订单和数据 Tab 当前仍使用 `PlaceholderTabRootView`,用于保留入口和验证 Tab 内导航。
## 导航流程
1. `MainTabsView` 从 Environment 读取 `AppRouter`
2. `TabView` 使用 `appRouter.selectedTab` 作为选中状态。
3. 每个 Tab 创建自己的 `NavigationStack(path:)`
4. 路径绑定来自 `appRouter.binding(for:)`
5. 占位页点击“打开详情”时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`
6. `navigationDestination` 根据 `AppRoute` 展示详情页。
7. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。
## 后续迁移规则
迁移新页面时:
- 优先替换对应 Tab 的 RootView。
- 保持每个 Tab 自己的 `NavigationStack`
- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。
- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。
- 普通业务子页面默认隐藏 TabBar只有确有产品需求时才在 `AppRoute` 策略中单独放开。
- 真实业务页面接入后,应同步补充该模块文档和单元测试。

View File

@ -0,0 +1,38 @@
//
// MainTabsView.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import SwiftUI
/// Tab Tab NavigationStack
struct MainTabsView: View {
@Environment(AppRouter.self) private var appRouter
var body: some View {
@Bindable var appRouter = appRouter
TabView(selection: $appRouter.selectedTab) {
ForEach(AppTab.allCases) { tab in
NavigationStack(path: appRouter.binding(for: tab)) {
tab.rootView
.navigationTitle(tab.title)
.navigationDestination(for: AppRoute.self) { route in
route.destinationView
.toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar)
}
}
.environment(appRouter.router(for: tab))
.tabItem { tab.label }
.tag(tab)
}
}
}
}
#Preview {
MainTabsView()
.environment(AppRouter())
}

View File

@ -0,0 +1,77 @@
//
// PlaceholderRootViews.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import SwiftUI
///
struct HomeRootView: View {
var body: some View {
HomeView()
}
}
///
struct OrdersRootView: View {
var body: some View {
PlaceholderTabRootView(title: "订单", systemImage: "doc.text")
}
}
///
struct StatisticsRootView: View {
var body: some View {
PlaceholderTabRootView(title: "数据", systemImage: "chart.bar")
}
}
///
struct ProfileRootView: View {
var body: some View {
ProfileView()
}
}
/// Tab
private struct PlaceholderTabRootView: View {
@Environment(RouterPath.self) private var router
let title: String
let systemImage: String
var body: some View {
VStack(spacing: 16) {
Image(systemName: systemImage)
.font(.system(size: 44, weight: .semibold))
.foregroundStyle(.tint)
Text(title)
.font(.title2.weight(.semibold))
Button {
router.navigate(to: .placeholder(title: "\(title)详情"))
} label: {
Label("打开详情", systemImage: "chevron.right")
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
}
}
/// Tab NavigationStack
struct PlaceholderDetailView: View {
let title: String
var body: some View {
Text(title)
.font(.title3.weight(.medium))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
.navigationTitle(title)
}
}

View File

@ -0,0 +1,66 @@
//
// ProfileAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import Foundation
import Observation
@MainActor
@Observable
/// API
final class ProfileAPI {
@ObservationIgnored private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func userInfo() async throws -> UserInfoResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/userinfo"
)
)
}
///
func realNameInfo() async throws -> RealNameInfoResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/real-name/info"
)
)
}
///
func updateUserInfo(nickname: String? = nil, password: String? = nil, avatar: String? = nil) async throws {
let request = UpdateInfoRequest(nickname: nickname, password: password, avatar: avatar)
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/userinfo-update",
body: request
)
)
}
/// URL OSS
func updateUserAvatarURL(_ fileURL: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/userinfo-update-avatar-url",
queryItems: [URLQueryItem(name: "file_url", value: fileURL)]
)
)
}
}
extension ProfileAPI: UserProfileServing {}

View File

@ -0,0 +1,142 @@
//
// ProfileModels.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import Foundation
///
struct UserInfoResponse: Decodable, Equatable {
let avatar: String
let realName: String
let phone: String
let nickname: String
let roleName: String
let status: Int
let statusName: String
/// JSON
enum CodingKeys: String, CodingKey {
case avatar
case realName = "real_name"
case phone
case nickname
case roleName = "role_name"
case status
case statusName = "status_name"
}
///
init(
avatar: String = "",
realName: String = "",
phone: String = "",
nickname: String = "",
roleName: String = "",
status: Int = 0,
statusName: String = ""
) {
self.avatar = avatar
self.realName = realName
self.phone = phone
self.nickname = nickname
self.roleName = roleName
self.status = status
self.statusName = statusName
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
avatar = try container.decodeLossyString(forKey: .avatar)
realName = try container.decodeLossyString(forKey: .realName)
phone = try container.decodeLossyString(forKey: .phone)
nickname = try container.decodeLossyString(forKey: .nickname)
roleName = try container.decodeLossyString(forKey: .roleName)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusName = try container.decodeLossyString(forKey: .statusName)
}
}
///
struct UpdateInfoRequest: Encodable {
let nickname: String?
let password: String?
let avatar: String?
}
///
struct RealNameInfoResponse: Decodable, Equatable {
let realNameInfo: RealNameInfo?
/// JSON
enum CodingKeys: String, CodingKey {
case realNameInfo = "real_name_info"
}
}
///
struct RealNameInfo: Decodable, Equatable {
let realName: String
let auditStatus: Int
let auditStatusText: String?
let rejectReason: String?
/// JSON
enum CodingKeys: String, CodingKey {
case realName = "real_name"
case auditStatus = "audit_status"
case auditStatusText = "audit_status_text"
case rejectReason = "reject_reason"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
realName = try container.decodeLossyString(forKey: .realName)
auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0
auditStatusText = try container.decodeIfPresent(String.self, forKey: .auditStatusText)
rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason)
}
}
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
}
}

View File

@ -0,0 +1,75 @@
# Profile 模块业务逻辑
## 模块职责
Profile 模块负责“我的/个人信息”页面,包括用户资料展示、昵称编辑、密码修改、实名认证状态展示和退出登录。
该模块聚焦个人资料业务:
- 拉取用户基础资料。
- 拉取实名认证信息。
- 展示头像、昵称、UID、手机号、账号状态和实名认证状态。
- 修改昵称。
- 修改密码。
- 退出登录入口。
登录态清理和缓存清理由 App 模块的 `AuthSessionCoordinator` 统一处理。
## 核心对象
- `ProfileView`:个人信息页 UI负责展示资料、编辑入口、密码弹窗和退出确认。
- `ProfileViewModel`:维护资料加载状态、编辑状态、保存状态和展示文案。
- `ProfileAPI`:封装用户资料、实名认证和资料更新接口。
- `UserInfoResponse`:用户基础资料。
- `RealNameInfoResponse` / `RealNameInfo`:实名认证信息。
- `UpdateInfoRequest`:昵称、密码或头像更新请求体。
## 加载流程
1. `ProfileView.task` 进入页面时调用 `reloadProfile(showToast: false)`
2. 下拉刷新时调用 `reloadProfile(showToast: true)`
3. `ProfileViewModel.reload` 并发请求:
- `ProfileAPI.userInfo`
- `ProfileAPI.realNameInfo`
4. 请求成功后更新 `userInfo``realNameInfo`
5. `ProfileView` 将最新 `userInfo` 交给 `AuthSessionCoordinator.refreshCachedProfile`
6. 协调器更新 `AccountContext.profile`,并同步刷新账号快照。
## 昵称编辑流程
1. 用户点击编辑按钮。
2. `ProfileViewModel.beginEditing` 进入编辑状态,并把当前昵称写入编辑框。
3. 用户提交后,`ProfileViewModel.saveProfile` 校验昵称非空。
4. 昵称未变化时直接退出编辑状态。
5. 昵称变化时调用 `ProfileAPI.updateUserInfo(nickname:)`
6. 接口成功后本地更新 `userInfo.nickname`
7. `ProfileView` 刷新全局账号资料和账号快照。
## 密码修改流程
1. 用户打开 `PasswordUpdateSheet`
2. 输入新密码并点击完成。
3. `ProfileViewModel.updatePassword` 校验密码至少 6 位。
4. 校验通过后调用 `ProfileAPI.updateUserInfo(password:)`
5. 成功后关闭弹窗并展示 Toast。
密码不会写入本地缓存。
## 退出登录流程
1. 用户点击退出登录。
2. `ProfileView` 展示确认弹窗。
3. 用户确认后调用 `AuthSessionCoordinator.logout`
4. 协调器清空正式 token、账号快照、账号上下文、路由栈和 Toast。
5. `AppSession` 切换为 `loggedOut`,根视图回到登录页。
6. 上次手机号和协议状态等非敏感偏好保留。
## 状态展示规则
- 昵称为空时展示“未设置昵称”。
- 真实姓名、手机号和账号状态为空时展示 `--`
- 实名认证状态:
- `auditStatus == 2`:已实名认证。
- `auditStatus == 3`:审核不通过。
- 其他状态:审核中。
- 无实名认证信息时:点击去实名认证。

View File

@ -0,0 +1,157 @@
//
// ProfileViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import Foundation
import Observation
@MainActor
@Observable
/// ViewModel
final class ProfileViewModel {
var userInfo: UserInfoResponse?
var realNameInfo: RealNameInfo?
var isLoading = false
var isSaving = false
var isEditingProfile = false
var editingNickname = ""
var displayNickname: String {
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
}
var displayRealName: String {
nonEmpty(userInfo?.realName) ?? "--"
}
var displayPhone: String {
nonEmpty(userInfo?.phone) ?? "--"
}
var displayAvatarURL: String {
nonEmpty(userInfo?.avatar) ?? ""
}
var accountStatusText: String {
nonEmpty(userInfo?.statusName) ?? "--"
}
var realNameStatusText: String {
guard let realNameInfo else { return "点击去实名认证" }
switch realNameInfo.auditStatus {
case 2:
return "已实名认证"
case 3:
return "审核不通过"
default:
return "审核中"
}
}
///
func reload(api: ProfileAPI) async throws {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
async let user = api.userInfo()
async let realName = api.realNameInfo()
let result = try await (user, realName)
userInfo = result.0
realNameInfo = result.1.realNameInfo
}
///
func beginEditing() {
editingNickname = displayNickname == "未设置昵称" ? "" : displayNickname
isEditingProfile = true
}
/// 退
func cancelEditing() {
editingNickname = ""
isEditingProfile = false
}
/// userInfo
func saveProfile(api: ProfileAPI) async throws {
let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
guard !nextNickname.isEmpty else {
throw ProfileValidationError.emptyNickname
}
let nicknameChanged = nextNickname != displayNickname
guard nicknameChanged else {
cancelEditing()
return
}
guard !isSaving else { return }
isSaving = true
defer { isSaving = false }
try await api.updateUserInfo(nickname: nextNickname)
var nextUser = userInfo ?? UserInfoResponse()
nextUser = UserInfoResponse(
avatar: nextUser.avatar,
realName: nextUser.realName,
phone: nextUser.phone,
nickname: nextNickname,
roleName: nextUser.roleName,
status: nextUser.status,
statusName: nextUser.statusName
)
userInfo = nextUser
cancelEditing()
}
///
func updatePassword(_ password: String, api: ProfileAPI) async throws {
let nextPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
guard nextPassword.count >= 6 else {
throw ProfileValidationError.shortPassword
}
guard !isSaving else { return }
isSaving = true
defer { isSaving = false }
try await api.updateUserInfo(password: nextPassword)
}
/// userInfo AccountProfile userId
func accountProfileFallback(_ fallback: AccountProfile?) -> AccountProfile? {
guard let userInfo else { return fallback }
return AccountProfile(
userId: fallback?.userId ?? "",
displayName: nonEmpty(userInfo.nickname) ?? nonEmpty(userInfo.realName) ?? fallback?.displayName ?? "未设置昵称",
phone: nonEmpty(userInfo.phone) ?? fallback?.phone,
avatarURL: nonEmpty(userInfo.avatar) ?? fallback?.avatarURL
)
}
///
private func nonEmpty(_ value: String?) -> String? {
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return text.isEmpty ? nil : text
}
}
///
enum ProfileValidationError: LocalizedError {
case emptyNickname
case shortPassword
var errorDescription: String? {
switch self {
case .emptyNickname:
"请输入昵称"
case .shortPassword:
"密码至少 6 位"
}
}
}

View File

@ -0,0 +1,636 @@
//
// ProfileView.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import SwiftUI
/// 退
struct ProfileView: View {
@Environment(AppSession.self) private var appSession
@Environment(AccountContext.self) private var accountContext
@Environment(PermissionContext.self) private var permissionContext
@Environment(ScenicSpotContext.self) private var scenicSpotContext
@Environment(AppRouter.self) private var appRouter
@Environment(ProfileAPI.self) private var profileAPI
@Environment(ToastCenter.self) private var toastCenter
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var viewModel = ProfileViewModel()
@State private var showsPasswordSheet = false
@State private var showsLogoutConfirm = false
@FocusState private var nicknameFocused: Bool
private var contentMaxWidth: CGFloat {
horizontalSizeClass == .regular ? 720 : .infinity
}
var body: some View {
ScrollView {
VStack(spacing: 22) {
header
infoCard
logoutButton
}
.padding(.horizontal, 19)
.padding(.top, 24)
.padding(.bottom, 24)
.frame(maxWidth: contentMaxWidth)
.frame(maxWidth: .infinity)
}
.background(profileBackground.ignoresSafeArea())
.navigationTitle("个人信息")
.navigationBarTitleDisplayMode(.inline)
.task {
await reloadProfile(showToast: false)
}
.refreshable {
await reloadProfile(showToast: true)
}
.sheet(isPresented: $showsPasswordSheet) {
PasswordUpdateSheet { password in
await updatePassword(password)
}
}
.confirmationDialog("确认退出当前账号?", isPresented: $showsLogoutConfirm, titleVisibility: .visible) {
Button("退出登录", role: .destructive) {
authSessionCoordinator.logout(
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
scenicSpotContext: scenicSpotContext,
appRouter: appRouter,
toastCenter: toastCenter
)
}
Button("取消", role: .cancel) {}
}
.overlay {
if viewModel.isLoading && viewModel.userInfo == nil {
ProgressView()
.controlSize(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white.opacity(0.35))
}
}
.onChange(of: viewModel.isEditingProfile) { _, isEditing in
guard isEditing else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
nicknameFocused = true
}
}
}
private var profileBackground: some View {
LinearGradient(
colors: [
Color(hex: 0xF7FAFF),
Color(hex: 0xF3F7FD),
Color.white
],
startPoint: .top,
endPoint: .bottom
)
}
private var header: some View {
HStack(alignment: .center, spacing: 14) {
avatarImage
VStack(alignment: .leading, spacing: 8) {
if viewModel.isEditingProfile {
TextField("请输入昵称", text: nicknameBinding)
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(Color(hex: 0x252525))
.tint(AppDesign.primary)
.textFieldStyle(.plain)
.focused($nicknameFocused)
.submitLabel(.done)
.padding(.horizontal, 10)
.frame(height: 42)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(AppDesign.primary, lineWidth: 1)
}
.onSubmit {
Task { await saveProfileEdits() }
}
} else {
Text(viewModel.displayNickname)
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(Color(hex: 0x252525))
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
}
Text("UID: \(accountContext.profile?.userId.isEmpty == false ? accountContext.profile?.userId ?? "--" : "--")")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color(hex: 0x606A7A))
.lineLimit(1)
}
.layoutPriority(1)
Spacer(minLength: 0)
Button {
if viewModel.isEditingProfile {
Task { await saveProfileEdits() }
} else {
viewModel.beginEditing()
}
} label: {
ZStack {
if viewModel.isSaving && viewModel.isEditingProfile {
ProgressView()
.tint(.white)
.scaleEffect(0.82)
} else {
Image(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil")
.font(.system(size: 18, weight: .semibold))
}
}
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.background(
LinearGradient(
colors: [Color(hex: 0x238BFF), Color(hex: 0x006BFF)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: Circle()
)
.shadow(color: Color(hex: 0x0073FF, alpha: 0.24), radius: 10, x: 0, y: 5)
}
.buttonStyle(.plain)
.disabled(viewModel.isSaving)
.opacity(viewModel.isSaving ? 0.6 : 1)
.accessibilityLabel(viewModel.isEditingProfile ? "完成编辑" : "编辑个人信息")
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 18)
.padding(.vertical, 21)
.frame(minHeight: 132)
.background {
RoundedRectangle(cornerRadius: 17, style: .continuous)
.fill(.white)
ProfileHeaderWaveShape()
.fill(
LinearGradient(
colors: [
Color(hex: 0xBFDFFF, alpha: 0.18),
Color(hex: 0x8EC4FF, alpha: 0.28)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
ProfileHeaderWaveShape(phase: 0.35)
.fill(Color(hex: 0xE4F1FF, alpha: 0.62))
}
.clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 17, style: .continuous)
.stroke(Color(hex: 0xE4ECF7), lineWidth: 1)
}
.shadow(color: Color(hex: 0xC8D7EA, alpha: 0.32), radius: 16, x: 0, y: 8)
}
private var avatarImage: some View {
Button {
toastCenter.show("头像上传待接入阿里云 OSS")
} label: {
AsyncAvatarImage(urlString: viewModel.displayAvatarURL)
.frame(width: 86, height: 86)
.clipShape(Circle())
.overlay {
Circle()
.stroke(.white, lineWidth: 3)
}
.overlay(alignment: .bottomTrailing) {
Image(systemName: "camera.fill")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
.frame(width: 26, height: 26)
.background(AppDesign.primary, in: Circle())
.overlay {
Circle().stroke(.white, lineWidth: 2)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel("头像")
}
private var infoCard: some View {
VStack(spacing: 0) {
infoRow(title: "姓名") {
plainInfoValue(viewModel.displayRealName)
}
divider
Button {
toastCenter.show("账号切换待接入")
} label: {
infoRow(title: "当前账号") {
VStack(alignment: .trailing, spacing: 3) {
rowValueText(currentAccountDisplayName)
Text(currentAccountTypeText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(1)
}
rowChevron
}
}
.buttonStyle(.plain)
divider
infoRow(title: "手机号") {
plainInfoValue(viewModel.displayPhone)
}
divider
Button {
showsPasswordSheet = true
} label: {
infoRow(title: "修改密码") {
rowChevron
}
}
.buttonStyle(.plain)
divider
Button {
toastCenter.show("实名认证页面待接入")
} label: {
infoRow(title: "认证状态") {
realNameStatusView
rowChevron
}
}
.buttonStyle(.plain)
divider
infoRow(title: "账号状态") {
accountStatusView
}
divider
infoRow(title: "当前景区") {
scenicStatusView
}
}
.padding(.horizontal, 24)
.padding(.vertical, 4)
.background(.white, in: RoundedRectangle(cornerRadius: 17, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 17, style: .continuous)
.stroke(Color(hex: 0xEEF3FA), lineWidth: 1)
}
.shadow(color: Color(hex: 0xC8D7EA, alpha: 0.30), radius: 16, x: 0, y: 8)
}
private var divider: some View {
Rectangle()
.fill(Color(hex: 0xE8EDF4))
.frame(height: 1)
}
private var logoutButton: some View {
Button {
showsLogoutConfirm = true
} label: {
Text("退出登录")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color(hex: 0xFF3B3B))
.frame(maxWidth: .infinity)
.frame(height: 58)
.background(.white, in: RoundedRectangle(cornerRadius: 17, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 17, style: .continuous)
.stroke(Color(hex: 0xEEF3FA), lineWidth: 1)
}
.shadow(color: Color(hex: 0xC8D7EA, alpha: 0.24), radius: 14, x: 0, y: 7)
}
.buttonStyle(.plain)
}
///
private func infoRow<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
HStack(spacing: 8) {
Text(title)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color(hex: 0x101827))
.frame(width: 104, alignment: .leading)
Spacer(minLength: 4)
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 69)
.contentShape(Rectangle())
}
@ViewBuilder
///
private func plainInfoValue(_ text: String) -> some View {
if text == "--" {
statusBadge(text: text, foreground: Color(hex: 0x606A7A), background: Color(hex: 0xF0F2F6))
} else {
rowValueText(text)
}
}
///
private func rowValueText(_ text: String) -> some View {
Text(text)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(Color(hex: 0x333333))
.lineLimit(1)
.fixedSize(horizontal: false, vertical: true)
}
private var rowChevron: some View {
Image(systemName: "chevron.right")
.font(.system(size: 22, weight: .medium))
.foregroundStyle(Color(hex: 0x9AA1AA))
}
@ViewBuilder
private var realNameStatusView: some View {
if viewModel.realNameInfo == nil {
Text(viewModel.realNameStatusText)
.font(.system(size: 17, weight: .medium))
.foregroundStyle(Color(hex: 0xFF7B00))
.lineLimit(1)
} else {
statusBadge(
text: viewModel.realNameStatusText,
icon: viewModel.realNameInfo?.auditStatus == 2 ? "checkmark.shield.fill" : nil,
foreground: realNameStatusColor,
background: realNameStatusBackground
)
}
}
private var accountStatusView: some View {
let text = viewModel.accountStatusText
return Group {
if text == "--" {
statusBadge(text: text, foreground: Color(hex: 0x606A7A), background: Color(hex: 0xF0F2F6))
} else {
statusBadge(text: text, icon: "checkmark.circle.fill", foreground: Color(hex: 0x14964A), background: Color(hex: 0xDDF8E9))
}
}
}
private var scenicStatusView: some View {
let text = nonEmpty(accountContext.currentScenic?.name) ?? "--"
return Group {
if text == "--" {
statusBadge(text: text, foreground: Color(hex: 0x606A7A), background: Color(hex: 0xF0F2F6))
} else {
statusBadge(text: text, icon: "mappin.circle.fill", foreground: AppDesign.primary, background: Color(hex: 0xE7F1FF))
}
}
}
///
private func statusBadge(text: String, icon: String? = nil, foreground: Color, background: Color) -> some View {
HStack(spacing: 7) {
if let icon {
Image(systemName: icon)
.font(.system(size: 17, weight: .semibold))
}
Text(text)
.lineLimit(1)
}
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(foreground)
.padding(.horizontal, icon == nil ? 14 : 12)
.frame(height: 34)
.background(background, in: Capsule())
}
private var realNameStatusColor: Color {
guard let realNameInfo = viewModel.realNameInfo else { return Color(hex: 0xFF7B00) }
switch realNameInfo.auditStatus {
case 2:
return Color(hex: 0x14964A)
case 3:
return Color(hex: 0xEF4444)
default:
return Color(hex: 0xFF7B00)
}
}
private var realNameStatusBackground: Color {
guard let realNameInfo = viewModel.realNameInfo else { return Color(hex: 0xF4F4F4) }
switch realNameInfo.auditStatus {
case 2:
return Color(hex: 0xDDF8E9)
case 3:
return Color(hex: 0xFFE7E7)
default:
return Color(hex: 0xFFF0E2)
}
}
private var currentAccountDisplayName: String {
nonEmpty(accountContext.currentStore?.name)
?? nonEmpty(accountContext.currentScenic?.name)
?? accountContext.profile?.displayName
?? viewModel.displayNickname
}
private var currentAccountTypeText: String {
if accountContext.currentStore != nil {
return "门店账号"
}
if accountContext.currentScenic != nil {
return "景区账号"
}
return nonEmpty(viewModel.userInfo?.roleName) ?? "账号"
}
private var nicknameBinding: Binding<String> {
Binding(
get: { viewModel.editingNickname },
set: { viewModel.editingNickname = $0 }
)
}
///
private func reloadProfile(showToast: Bool) async {
do {
try await viewModel.reload(api: profileAPI)
if let userInfo = viewModel.userInfo {
authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext)
} else {
accountContext.replaceProfile(viewModel.accountProfileFallback(accountContext.profile))
}
if showToast {
toastCenter.show("个人信息已刷新")
}
} catch is CancellationError {
return
} catch {
toastCenter.show(error.localizedDescription)
}
}
///
private func saveProfileEdits() async {
do {
try await viewModel.saveProfile(api: profileAPI)
if let userInfo = viewModel.userInfo {
authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext)
} else {
accountContext.replaceProfile(viewModel.accountProfileFallback(accountContext.profile))
}
toastCenter.show("个人信息已保存")
} catch {
toastCenter.show(error.localizedDescription)
}
}
///
private func updatePassword(_ password: String) async {
do {
try await viewModel.updatePassword(password, api: profileAPI)
showsPasswordSheet = false
toastCenter.show("密码修改成功")
} catch {
toastCenter.show(error.localizedDescription)
}
}
///
private func nonEmpty(_ value: String?) -> String? {
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return text.isEmpty ? nil : text
}
}
///
private struct AsyncAvatarImage: View {
let urlString: String
var body: some View {
if let url = URL(string: urlString), !urlString.isEmpty {
AsyncImage(url: url) { phase in
switch phase {
case let .success(image):
image
.resizable()
.scaledToFill()
case .failure, .empty:
placeholder
@unknown default:
placeholder
}
}
} else {
placeholder
}
}
private var placeholder: some View {
Image(systemName: "person.fill")
.font(.system(size: 44, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppDesign.primarySoft)
}
}
///
private struct ProfileHeaderWaveShape: Shape {
var phase: CGFloat = 0
///
func path(in rect: CGRect) -> Path {
var path = Path()
let startY = rect.height * (0.94 - phase * 0.16)
path.move(to: CGPoint(x: rect.width * (0.26 + phase * 0.16), y: rect.height))
path.addCurve(
to: CGPoint(x: rect.width, y: rect.height * (0.26 + phase * 0.08)),
control1: CGPoint(x: rect.width * (0.55 + phase * 0.06), y: startY),
control2: CGPoint(x: rect.width * (0.70 + phase * 0.08), y: rect.height * (0.40 + phase * 0.14))
)
path.addLine(to: CGPoint(x: rect.width, y: rect.height))
path.closeSubpath()
return path
}
}
///
private struct PasswordUpdateSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var password = ""
let onSubmit: (String) async -> Void
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 16) {
SecureField("请输入新密码", text: $password)
.textContentType(.newPassword)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.padding(.horizontal, 12)
.frame(height: 48)
.background(Color(hex: 0xF5F7FA), in: RoundedRectangle(cornerRadius: 8))
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(Color(hex: 0xE2E8F0), lineWidth: 1)
}
Text("密码至少 6 位。")
.font(.system(size: 13))
.foregroundStyle(AppDesign.textSecondary)
Spacer()
}
.padding(16)
.navigationTitle("修改密码")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("完成") {
Task { await onSubmit(password) }
}
.disabled(password.trimmingCharacters(in: .whitespacesAndNewlines).count < 6)
}
}
}
.presentationDetents([.height(220)])
}
}
#Preview {
NavigationStack {
ProfileView()
.environment(AppSession())
.environment(AccountContext())
.environment(PermissionContext())
.environment(ScenicSpotContext())
.environment(AppRouter())
.environment(ToastCenter())
.environment(ProfileAPI(client: APIClient()))
.environment(AuthSessionCoordinator())
}
}