Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
96
suixinkan_ios/Features/Profile/API/ProfileAPI.swift
Normal file
96
suixinkan_ios/Features/Profile/API/ProfileAPI.swift
Normal file
@ -0,0 +1,96 @@
|
||||
//
|
||||
// ProfileAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 个人信息 API,封装“我的”页面需要的用户资料和资料更新接口。
|
||||
final class ProfileAPI {
|
||||
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 switchableAccounts() async throws -> V9AuthResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/v9/accounts"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取当前登录用户的实名认证状态。
|
||||
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)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 发送实名认证短信验证码。
|
||||
func realNameSmsVerifyCode() async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/real-name/sms-verify-code",
|
||||
body: EmptyPayload()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交实名认证资料。
|
||||
func realNameSubmit(_ request: RealNameAuthRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/real-name/submit",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileAPI: UserProfileServing {}
|
||||
48
suixinkan_ios/Features/Profile/Models/AgreementPage.swift
Normal file
48
suixinkan_ios/Features/Profile/Models/AgreementPage.swift
Normal file
@ -0,0 +1,48 @@
|
||||
//
|
||||
// AgreementPage.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 协议和说明页面枚举,描述设置中心可打开的 H5 页面。
|
||||
enum AgreementPage: Hashable, Identifiable {
|
||||
case about
|
||||
case userAgreement
|
||||
case privacyPolicy
|
||||
case walletUserNotice
|
||||
case walletPrivacy
|
||||
|
||||
var id: String { title }
|
||||
|
||||
/// 页面导航标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .about: "关于我们"
|
||||
case .userAgreement: "用户协议"
|
||||
case .privacyPolicy: "隐私政策"
|
||||
case .walletUserNotice: "钱包用户须知"
|
||||
case .walletPrivacy: "钱包隐私政策"
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面对应的 H5 地址。
|
||||
var url: URL {
|
||||
let path = switch self {
|
||||
case .about: "/h5/app/about-us"
|
||||
case .userAgreement: "/h5/app/user-agreement"
|
||||
case .privacyPolicy: "/h5/app/privacy-policy"
|
||||
case .walletUserNotice: "/h5/app/wallet-user-notice"
|
||||
case .walletPrivacy: "/h5/app/wallet-privacy"
|
||||
}
|
||||
return APIEnvironment.current.baseURL.appending(path: path)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置展示策略,集中处理版本号等纯展示逻辑。
|
||||
enum SettingsDisplayPolicy {
|
||||
/// 生成页面显示的版本号。
|
||||
nonisolated static func versionText(infoDictionary: [String: Any]? = Bundle.main.infoDictionary) -> String {
|
||||
AppClientInfo.appVersion(infoDictionary: infoDictionary)
|
||||
}
|
||||
}
|
||||
217
suixinkan_ios/Features/Profile/Models/ProfileModels.swift
Normal file
217
suixinkan_ios/Features/Profile/Models/ProfileModels.swift
Normal file
@ -0,0 +1,217 @@
|
||||
//
|
||||
// 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 idCardNo: String
|
||||
let auditStatus: Int
|
||||
let auditStatusText: String?
|
||||
let rejectReason: String?
|
||||
let startDate: String?
|
||||
let endDate: String?
|
||||
let isLongValid: Bool
|
||||
let frontUrl: String?
|
||||
let backUrl: String?
|
||||
let auditorId: Int?
|
||||
let auditor: RealNameAuditor?
|
||||
let auditAt: String?
|
||||
|
||||
/// 当前认证是否已审核通过。
|
||||
var verified: Bool {
|
||||
auditStatus == 2
|
||||
}
|
||||
|
||||
/// 实名认证信息的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case idCardNo = "id_card_no"
|
||||
case auditStatus = "audit_status"
|
||||
case auditStatusText = "audit_status_text"
|
||||
case rejectReason = "reject_reason"
|
||||
case startDate = "start_date"
|
||||
case endDate = "end_date"
|
||||
case isLongValid = "is_long_valid"
|
||||
case frontUrl = "front_url"
|
||||
case backUrl = "back_url"
|
||||
case auditorId = "auditor_id"
|
||||
case auditor
|
||||
case auditAt = "audit_at"
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容审核状态字段类型不稳定的情况。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
idCardNo = try container.decodeLossyString(forKey: .idCardNo)
|
||||
auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0
|
||||
auditStatusText = try container.decodeIfPresent(String.self, forKey: .auditStatusText)
|
||||
rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason)
|
||||
startDate = try container.decodeIfPresent(String.self, forKey: .startDate)
|
||||
endDate = try container.decodeIfPresent(String.self, forKey: .endDate)
|
||||
isLongValid = (try container.decodeLossyInt(forKey: .isLongValid) ?? 0) == 1
|
||||
frontUrl = try container.decodeIfPresent(String.self, forKey: .frontUrl)
|
||||
backUrl = try container.decodeIfPresent(String.self, forKey: .backUrl)
|
||||
auditorId = try container.decodeLossyInt(forKey: .auditorId)
|
||||
auditor = try container.decodeIfPresent(RealNameAuditor.self, forKey: .auditor)
|
||||
auditAt = try container.decodeIfPresent(String.self, forKey: .auditAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证审核人实体,表示后台审核人的基础展示信息。
|
||||
struct RealNameAuditor: Decodable, Equatable {
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
/// 审核人 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case 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 RealNameAuthRequest: Encodable, Equatable {
|
||||
let realName: String
|
||||
let idCardNo: String
|
||||
let smsVerifyCode: String
|
||||
let startDate: String
|
||||
let endDate: String
|
||||
let isLongValid: Int
|
||||
let frontUrl: String
|
||||
let backUrl: String
|
||||
|
||||
/// 实名认证提交请求 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case idCardNo = "id_card_no"
|
||||
case smsVerifyCode = "sms_verify_code"
|
||||
case startDate = "start_date"
|
||||
case endDate = "end_date"
|
||||
case isLongValid = "is_long_valid"
|
||||
case frontUrl = "front_url"
|
||||
case backUrl = "back_url"
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
122
suixinkan_ios/Features/Profile/Profile.md
Normal file
122
suixinkan_ios/Features/Profile/Profile.md
Normal file
@ -0,0 +1,122 @@
|
||||
# Profile 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Profile 模块负责“我的/个人信息”页面及其二级页面,包括用户资料展示、昵称编辑、账号切换、密码修改、实名认证、系统设置、协议页和退出登录。
|
||||
|
||||
该模块聚焦个人资料业务:
|
||||
- 拉取用户基础资料。
|
||||
- 拉取实名认证信息。
|
||||
- 展示头像、昵称、UID、手机号、账号状态和实名认证状态。
|
||||
- 修改昵称和头像。
|
||||
- 修改密码。
|
||||
- 切换当前景区/门店业务账号。
|
||||
- 提交实名认证基础资料。
|
||||
- 展示系统设置、版本号、下载链接和协议 H5 页面。
|
||||
- 退出登录入口。
|
||||
|
||||
登录态清理和缓存清理由 App 模块的 `AuthSessionCoordinator` 统一处理。
|
||||
|
||||
DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,进入 `DebugHomeMenuPreviewView` 后可查看并跳转所有已知首页菜单,不读取角色权限。该入口只用于迁移预览和调试,不进入 Release 包。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `ProfileView`:个人信息页 UI,负责展示资料、编辑入口、密码弹窗和退出确认。
|
||||
- `ProfileViewModel`:维护资料加载状态、编辑状态、保存状态和展示文案。
|
||||
- `ProfileRoute`:个人中心二级页面路由。
|
||||
- `AccountSwitchView` / `AccountSwitchViewModel`:账号切换页面和状态逻辑。
|
||||
- `RealNameAuthView` / `RealNameAuthViewModel`:实名认证页面和表单逻辑。
|
||||
- `SettingsCenterView` / `AgreementView`:设置中心和协议 H5 页面。
|
||||
- `DebugHomeMenuPreviewView`:DEBUG 专用首页菜单预览页,不参与正式业务流程。
|
||||
- `ProfileAPI`:封装用户资料、实名认证和资料更新接口。
|
||||
- `OSSUploadService`:上传头像和实名认证证件图片,返回可提交给业务接口的 OSS URL。
|
||||
- `UserInfoResponse`:用户基础资料。
|
||||
- `RealNameInfoResponse` / `RealNameInfo`:实名认证信息。
|
||||
- `UpdateInfoRequest`:昵称、密码或头像更新请求体。
|
||||
- `RealNameAuthRequest`:实名认证提交请求体。
|
||||
|
||||
## 加载流程
|
||||
|
||||
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. 用户点击头像时通过 `PhotosPicker` 选择本地图片。
|
||||
4. `AvatarImageProcessor` 将头像压缩为 JPEG,并暂存在内存中。
|
||||
5. 用户提交后,`ProfileViewModel.saveProfile` 校验昵称非空。
|
||||
6. 头像变化时先调用 `OSSUploadService.uploadUserAvatar` 上传到 OSS。
|
||||
7. 头像上传成功后调用 `ProfileAPI.updateUserAvatarURL` 回写头像 URL。
|
||||
8. 昵称变化时调用 `ProfileAPI.updateUserInfo(nickname:)`。
|
||||
9. 接口成功后本地更新 `userInfo.nickname` 和 `userInfo.avatar`。
|
||||
10. `ProfileView` 刷新全局账号资料和账号快照。
|
||||
|
||||
## 密码修改流程
|
||||
|
||||
1. 用户打开 `PasswordUpdateSheet`。
|
||||
2. 输入新密码并点击完成。
|
||||
3. `ProfileViewModel.updatePassword` 校验密码至少 6 位。
|
||||
4. 校验通过后调用 `ProfileAPI.updateUserInfo(password:)`。
|
||||
5. 成功后关闭弹窗并展示 Toast。
|
||||
|
||||
密码不会写入本地缓存。
|
||||
|
||||
## 账号切换流程
|
||||
|
||||
1. 用户点击“当前账号”进入 `AccountSwitchView`。
|
||||
2. `AccountSwitchViewModel.load` 调用 `ProfileAPI.switchableAccounts`,读取 `/api/app/v9/accounts`。
|
||||
3. 页面展示景区账号和门店账号,优先选中后端标记的当前账号,否则选中第一项。
|
||||
4. 用户确认后调用 `AuthAPI.setUser`,使用 `AccountSwitchAccount.toSetUserRequest()` 生成请求体。
|
||||
5. 切换成功后调用 `AuthSessionCoordinator.completeLogin`,重新写入正式 token、账号快照、权限、景区和门店上下文。
|
||||
6. `AppRouter.reset()` 清空旧路由栈并回到首页。
|
||||
|
||||
## 实名认证流程
|
||||
|
||||
1. 用户点击“认证状态”进入 `RealNameAuthView`。
|
||||
2. `RealNameAuthViewModel.load` 调用 `ProfileAPI.realNameInfo` 并回填姓名、身份证号、证件有效期、图片 URL 和审核状态。
|
||||
3. 用户可发送短信验证码,接口为 `/api/yf-handset-app/photog/real-name/sms-verify-code`。
|
||||
4. 用户通过图片卡片选择身份证人像面和国徽面。
|
||||
5. `RealNameImageProcessor` 将证件图片压缩为 JPEG,并暂存在内存中。
|
||||
6. 提交前校验姓名、身份证号、短信验证码、证件图片和有效期。
|
||||
7. 校验通过后先调用 `OSSUploadService.uploadRealNameImage` 上传证件图片。
|
||||
8. 上传成功后把 OSS URL 写入 `RealNameAuthRequest.frontUrl/backUrl`。
|
||||
9. 调用 `/api/yf-handset-app/photog/real-name/submit` 提交实名资料。
|
||||
10. 提交成功后重新加载实名认证信息。
|
||||
|
||||
证件图片的原始 Data、压缩 Data 和上传进度只保存在内存中,不写入本地缓存。
|
||||
|
||||
## 设置和协议流程
|
||||
|
||||
1. 用户点击“系统设置”进入 `SettingsCenterView`。
|
||||
2. 设置中心展示关于我们、系统版本、App 下载链接、用户协议和隐私政策。
|
||||
3. App 下载点击后复制 `/h5/app/download` 链接到剪贴板。
|
||||
4. 协议页使用 `AgreementView` 加载 `APIEnvironment.current.baseURL` 下的 H5 页面。
|
||||
|
||||
## 退出登录流程
|
||||
|
||||
1. 用户点击退出登录。
|
||||
2. `ProfileView` 展示确认弹窗。
|
||||
3. 用户确认后调用 `AuthSessionCoordinator.logout`。
|
||||
4. 协调器清空正式 token、账号快照、账号上下文、路由栈和 Toast。
|
||||
5. `AppSession` 切换为 `loggedOut`,根视图回到登录页。
|
||||
6. 上次手机号和协议状态等非敏感偏好保留。
|
||||
|
||||
## 状态展示规则
|
||||
|
||||
- 昵称为空时展示“未设置昵称”。
|
||||
- 真实姓名、手机号和账号状态为空时展示 `--`。
|
||||
- 实名认证状态:
|
||||
- `auditStatus == 2`:已实名认证。
|
||||
- `auditStatus == 3`:审核不通过。
|
||||
- 其他状态:审核中。
|
||||
- 无实名认证信息时:点击去实名认证。
|
||||
- 账号切换失败只展示 Toast,不清空登录态。
|
||||
- 实名认证失败只影响实名认证页面,不影响登录态和账号上下文。
|
||||
19
suixinkan_ios/Features/Profile/Routing/ProfileRoute.swift
Normal file
19
suixinkan_ios/Features/Profile/Routing/ProfileRoute.swift
Normal file
@ -0,0 +1,19 @@
|
||||
//
|
||||
// ProfileRoute.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 个人中心二级页面路由,集中声明“我的”Tab 可以 push 的页面。
|
||||
enum ProfileRoute: Hashable {
|
||||
case accountSwitch
|
||||
case realNameAuth
|
||||
case settings
|
||||
case agreement(AgreementPage)
|
||||
#if DEBUG
|
||||
case debugHomeMenus
|
||||
#endif
|
||||
}
|
||||
@ -0,0 +1,263 @@
|
||||
//
|
||||
// AccountSwitchViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。
|
||||
final class AccountSwitchViewController: UIViewController {
|
||||
|
||||
private let viewModel = AccountSwitchViewModel()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .plain)
|
||||
table.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||
table.separatorStyle = .none
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID)
|
||||
return table
|
||||
}()
|
||||
|
||||
private lazy var confirmButton: UIButton = {
|
||||
let button = makePrimaryButton(title: "确认切换")
|
||||
button.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "账号切换"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(confirmButton)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(confirmButton.snp.top).offset(-12)
|
||||
}
|
||||
confirmButton.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(14)
|
||||
make.height.equalTo(50)
|
||||
}
|
||||
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
self?.updateConfirmButton()
|
||||
}
|
||||
Task { await loadAccounts() }
|
||||
}
|
||||
|
||||
private func updateConfirmButton() {
|
||||
let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching
|
||||
confirmButton.isEnabled = enabled
|
||||
confirmButton.alpha = enabled ? 1 : 0.5
|
||||
}
|
||||
|
||||
private func loadAccounts(force: Bool = false) async {
|
||||
do {
|
||||
try await appServices.globalLoading.withOptionalLoading(!force && viewModel.accounts.isEmpty, message: "加载中...") {
|
||||
try await self.viewModel.load(
|
||||
api: self.appServices.profileAPI,
|
||||
force: force,
|
||||
currentAccountId: self.currentAccountId
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private var currentAccountId: String? {
|
||||
guard let current = appServices.accountContext.profile else { return nil }
|
||||
if let store = appServices.accountContext.currentStore {
|
||||
return "\(V9StoreUser.accountTypeValue)_\(store.id)"
|
||||
}
|
||||
if let scenic = appServices.accountContext.currentScenic {
|
||||
return "\(V9ScenicUser.accountTypeValue)_\(scenic.id)"
|
||||
}
|
||||
return current.userId.isEmpty ? nil : current.userId
|
||||
}
|
||||
|
||||
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
|
||||
viewModel.isCurrent(account, currentAccountId: currentAccountId)
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
guard let account = viewModel.selectedAccount else { return }
|
||||
if account.isCurrent || isCurrentAccount(account) {
|
||||
navigationController?.popViewController(animated: true)
|
||||
return
|
||||
}
|
||||
Task { await switchAccount(account) }
|
||||
}
|
||||
|
||||
private func switchAccount(_ account: AccountSwitchAccount) async {
|
||||
do {
|
||||
let response = try await appServices.globalLoading.withLoading(message: "切换中...") {
|
||||
try await self.viewModel.switchAccount(account, api: self.appServices.authAPI)
|
||||
}
|
||||
let username = nonEmpty(appServices.accountContext.profile?.phone)
|
||||
?? nonEmpty(account.phone)
|
||||
?? ""
|
||||
try await appServices.authSessionCoordinator.completeLogin(
|
||||
with: response,
|
||||
username: username,
|
||||
privacyAgreementAccepted: appServices.authSessionCoordinator.loginPreferences().privacyAgreementAccepted,
|
||||
appSession: appServices.appSession,
|
||||
accountContext: appServices.accountContext,
|
||||
permissionContext: appServices.permissionContext,
|
||||
profileAPI: appServices.profileAPI,
|
||||
accountContextAPI: appServices.accountContextAPI
|
||||
)
|
||||
appServices.appRouter.reset()
|
||||
showToast("账号已切换")
|
||||
navigationController?.popToRootViewController(animated: true)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountSwitchViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
viewModel.accounts.isEmpty && !viewModel.loading ? 1 : viewModel.accounts.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard !viewModel.accounts.isEmpty else {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(
|
||||
title: "暂无可切换账号",
|
||||
message: "当前登录账号下没有其他可切换账号。",
|
||||
systemImage: "person.crop.circle.badge.exclamationmark"
|
||||
)
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(280) }
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell
|
||||
let account = viewModel.accounts[indexPath.row]
|
||||
cell.configure(
|
||||
account: account,
|
||||
selected: viewModel.selectedAccountId == account.id,
|
||||
isCurrent: account.isCurrent || isCurrentAccount(account)
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.row < viewModel.accounts.count else { return }
|
||||
viewModel.select(viewModel.accounts[indexPath.row])
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
viewModel.accounts.isEmpty ? 280 : 92
|
||||
}
|
||||
}
|
||||
|
||||
private final class AccountSwitchCell: UITableViewCell {
|
||||
static let reuseID = "AccountSwitchCell"
|
||||
|
||||
private let avatarView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
private let tagLabel = UILabel()
|
||||
private let checkView = UIImageView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 12
|
||||
contentView.addSubview(card)
|
||||
|
||||
avatarView.layer.cornerRadius = 25
|
||||
avatarView.clipsToBounds = true
|
||||
avatarView.contentMode = .scaleAspectFill
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
subtitleLabel.font = .systemFont(ofSize: 13)
|
||||
subtitleLabel.textColor = AppDesignUIKit.textSecondary
|
||||
phoneLabel.font = .systemFont(ofSize: 12)
|
||||
phoneLabel.textColor = UIColor(hex: 0x9AA1AA)
|
||||
tagLabel.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||
tagLabel.textAlignment = .center
|
||||
tagLabel.layer.cornerRadius = 4
|
||||
tagLabel.clipsToBounds = true
|
||||
|
||||
card.addSubview(avatarView)
|
||||
card.addSubview(titleLabel)
|
||||
card.addSubview(subtitleLabel)
|
||||
card.addSubview(phoneLabel)
|
||||
card.addSubview(tagLabel)
|
||||
card.addSubview(checkView)
|
||||
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
|
||||
}
|
||||
avatarView.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview().inset(14)
|
||||
make.width.height.equalTo(50)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(avatarView.snp.trailing).offset(13)
|
||||
make.top.equalTo(avatarView).offset(2)
|
||||
make.trailing.lessThanOrEqualTo(tagLabel.snp.leading).offset(-8)
|
||||
}
|
||||
subtitleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(titleLabel)
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||
}
|
||||
phoneLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(titleLabel)
|
||||
make.top.equalTo(subtitleLabel.snp.bottom).offset(4)
|
||||
}
|
||||
tagLabel.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(14)
|
||||
make.top.equalToSuperview().offset(14)
|
||||
make.height.equalTo(22)
|
||||
make.width.greaterThanOrEqualTo(40)
|
||||
}
|
||||
checkView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(14)
|
||||
make.bottom.equalToSuperview().inset(14)
|
||||
make.width.height.equalTo(22)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) {
|
||||
let title = account.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
titleLabel.text = title.isEmpty ? account.accountTypeLabel : title
|
||||
subtitleLabel.text = account.subtitle
|
||||
phoneLabel.text = account.phone
|
||||
tagLabel.text = account.isStoreUser ? "门店" : "景区"
|
||||
tagLabel.textColor = account.isStoreUser ? UIColor(hex: 0x0F9F6E) : UIColor(hex: 0x7C3AED)
|
||||
tagLabel.backgroundColor = account.isStoreUser ? UIColor(hex: 0xE8F8F1) : UIColor(hex: 0xF3ECFF)
|
||||
checkView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
checkView.tintColor = selected ? AppDesignUIKit.primary : UIColor(hex: 0xB6BECA)
|
||||
avatarView.loadRemoteAvatar(urlString: account.avatar)
|
||||
if isCurrent {
|
||||
titleLabel.text = (titleLabel.text ?? "") + " (当前)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,278 @@
|
||||
//
|
||||
// ProfileViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 个人信息页,展示用户资料、账号状态和设置入口。
|
||||
final class ProfileViewController: UIViewController {
|
||||
|
||||
private let viewModel = ProfileViewModel()
|
||||
|
||||
private let avatarImageView = UIImageView()
|
||||
private let nicknameLabel = UILabel()
|
||||
private let uidLabel = UILabel()
|
||||
private let editButton = UIButton(type: .system)
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
private lazy var refreshControl = UIRefreshControl()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "个人信息"
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
setupHeader()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(150)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
tableView.refreshControl = refreshControl
|
||||
|
||||
viewModel.onChange = { [weak self] in self?.applyViewModel() }
|
||||
editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
|
||||
|
||||
let avatarTap = UITapGestureRecognizer(target: self, action: #selector(pickAvatar))
|
||||
avatarImageView.isUserInteractionEnabled = true
|
||||
avatarImageView.addGestureRecognizer(avatarTap)
|
||||
|
||||
Task { await reloadProfile(showToast: false) }
|
||||
}
|
||||
|
||||
private func setupHeader() {
|
||||
let header = UIView()
|
||||
header.backgroundColor = .white
|
||||
header.layer.cornerRadius = 17
|
||||
view.addSubview(header)
|
||||
|
||||
avatarImageView.layer.cornerRadius = 43
|
||||
avatarImageView.clipsToBounds = true
|
||||
avatarImageView.contentMode = .scaleAspectFill
|
||||
avatarImageView.backgroundColor = AppDesignUIKit.primarySoft
|
||||
|
||||
nicknameLabel.font = .systemFont(ofSize: 24, weight: .semibold)
|
||||
nicknameLabel.textColor = UIColor(hex: 0x252525)
|
||||
uidLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||
uidLabel.textColor = UIColor(hex: 0x606A7A)
|
||||
|
||||
editButton.setImage(UIImage(systemName: "pencil"), for: .normal)
|
||||
editButton.tintColor = .white
|
||||
editButton.backgroundColor = AppDesignUIKit.primary
|
||||
editButton.layer.cornerRadius = 22
|
||||
|
||||
header.addSubview(avatarImageView)
|
||||
header.addSubview(nicknameLabel)
|
||||
header.addSubview(uidLabel)
|
||||
header.addSubview(editButton)
|
||||
|
||||
header.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
|
||||
make.leading.trailing.equalToSuperview().inset(19)
|
||||
make.height.equalTo(132)
|
||||
}
|
||||
avatarImageView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(18)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(86)
|
||||
}
|
||||
nicknameLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(avatarImageView.snp.trailing).offset(14)
|
||||
make.trailing.lessThanOrEqualTo(editButton.snp.leading).offset(-8)
|
||||
make.top.equalTo(avatarImageView).offset(8)
|
||||
}
|
||||
uidLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(nicknameLabel)
|
||||
make.top.equalTo(nicknameLabel.snp.bottom).offset(8)
|
||||
}
|
||||
editButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(18)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(44)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
nicknameLabel.text = viewModel.displayNickname
|
||||
uidLabel.text = "UID: \(appServices.accountContext.profile?.userId ?? "--")"
|
||||
if let data = viewModel.pendingAvatarData, let image = UIImage(data: data) {
|
||||
avatarImageView.image = image
|
||||
} else {
|
||||
avatarImageView.loadRemoteAvatar(urlString: viewModel.displayAvatarURL)
|
||||
}
|
||||
editButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil"), for: .normal)
|
||||
editButton.isEnabled = !viewModel.isSaving
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
Task {
|
||||
await reloadProfile(showToast: true)
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadProfile(showToast: Bool) async {
|
||||
do {
|
||||
try await appServices.globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载资料...") {
|
||||
try await self.viewModel.reload(api: self.appServices.profileAPI)
|
||||
}
|
||||
} catch {
|
||||
if showToast { self.showToast(error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func editTapped() {
|
||||
if viewModel.isEditingProfile {
|
||||
Task { await saveProfileEdits() }
|
||||
} else {
|
||||
viewModel.beginEditing()
|
||||
presentNicknameEditor()
|
||||
}
|
||||
}
|
||||
|
||||
private func presentNicknameEditor() {
|
||||
let alert = UIAlertController(title: "编辑昵称", message: nil, preferredStyle: .alert)
|
||||
alert.addTextField { [weak self] field in
|
||||
field.text = self?.viewModel.editingNickname
|
||||
field.placeholder = "请输入昵称"
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.cancelEditing()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.viewModel.editingNickname = alert.textFields?.first?.text ?? ""
|
||||
Task { await self.saveProfileEdits() }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func saveProfileEdits() async {
|
||||
guard let scenicId = appServices.accountContext.currentScenic?.id else {
|
||||
showToast("请先选择景区")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await appServices.globalLoading.withLoading(message: "保存中...") {
|
||||
try await self.viewModel.saveProfile(
|
||||
api: self.appServices.profileAPI,
|
||||
uploader: self.appServices.ossUploadService,
|
||||
scenicId: scenicId
|
||||
)
|
||||
}
|
||||
showToast("资料已更新")
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func pickAvatar() {
|
||||
var config = PHPickerConfiguration()
|
||||
config.filter = .images
|
||||
config.selectionLimit = 1
|
||||
let picker = PHPickerViewController(configuration: config)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
@objc private func logoutTapped() {
|
||||
let alert = UIAlertController(title: "确认退出当前账号?", message: nil, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "退出登录", style: .destructive) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.appServices.authSessionCoordinator.logout(
|
||||
appSession: self.appServices.appSession,
|
||||
accountContext: self.appServices.accountContext,
|
||||
permissionContext: self.appServices.permissionContext,
|
||||
scenicSpotContext: self.appServices.scenicSpotContext,
|
||||
appRouter: self.appServices.appRouter,
|
||||
toastCenter: self.appServices.toastCenter
|
||||
)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 5 : 1
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
if indexPath.section == 1 {
|
||||
cell.textLabel?.text = "退出登录"
|
||||
cell.textLabel?.textColor = UIColor(hex: 0xEF4444)
|
||||
cell.textLabel?.textAlignment = .center
|
||||
return cell
|
||||
}
|
||||
switch indexPath.row {
|
||||
case 0:
|
||||
cell.textLabel?.text = "姓名"
|
||||
cell.detailTextLabel?.text = viewModel.displayRealName
|
||||
case 1:
|
||||
cell.textLabel?.text = "当前账号"
|
||||
cell.detailTextLabel?.text = appServices.accountContext.profile?.displayName ?? "--"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
case 2:
|
||||
cell.textLabel?.text = "手机号"
|
||||
cell.detailTextLabel?.text = viewModel.displayPhone
|
||||
case 3:
|
||||
cell.textLabel?.text = "实名认证"
|
||||
cell.detailTextLabel?.text = viewModel.realNameStatusText
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
default:
|
||||
cell.textLabel?.text = "设置"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
}
|
||||
cell.selectionStyle = indexPath.row == 0 || indexPath.row == 2 ? .none : .default
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if indexPath.section == 1 {
|
||||
logoutTapped()
|
||||
return
|
||||
}
|
||||
switch indexPath.row {
|
||||
case 1:
|
||||
HomeMenuRouting.pushProfile(.accountSwitch, from: self)
|
||||
case 3:
|
||||
HomeMenuRouting.pushProfile(.realNameAuth, from: self)
|
||||
case 4:
|
||||
HomeMenuRouting.pushProfile(.settings, from: self)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||||
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
||||
DispatchQueue.main.async {
|
||||
do {
|
||||
try self.viewModel.prepareAvatarImage(data: data)
|
||||
} catch {
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,261 @@
|
||||
//
|
||||
// RealNameAuthViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 实名认证页面,展示审核状态并允许提交基础实名资料。
|
||||
final class RealNameAuthViewController: UIViewController {
|
||||
|
||||
private let viewModel = RealNameAuthViewModel()
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
|
||||
private let realNameField = UITextField()
|
||||
private let idCardField = UITextField()
|
||||
private let smsCodeField = UITextField()
|
||||
private let frontImageView = UIImageView()
|
||||
private let backImageView = UIImageView()
|
||||
private let statusLabel = UILabel()
|
||||
private let longValidSwitch = UISwitch()
|
||||
private let startDatePicker = UIDatePicker()
|
||||
private let endDatePicker = UIDatePicker()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "实名认证"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
setupForm()
|
||||
viewModel.onChange = { [weak self] in self?.applyViewModel() }
|
||||
Task { await loadInfo() }
|
||||
}
|
||||
|
||||
private func setupForm() {
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(scrollView).offset(-32)
|
||||
}
|
||||
|
||||
[realNameField, idCardField, smsCodeField].forEach {
|
||||
$0.borderStyle = .roundedRect
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
}
|
||||
realNameField.placeholder = "请输入姓名"
|
||||
idCardField.placeholder = "请输入身份证号码"
|
||||
smsCodeField.placeholder = "请输入短信验证码"
|
||||
smsCodeField.keyboardType = .numberPad
|
||||
|
||||
[frontImageView, backImageView].forEach {
|
||||
$0.backgroundColor = UIColor(hex: 0xF8FAFC)
|
||||
$0.contentMode = .scaleAspectFit
|
||||
$0.layer.cornerRadius = 8
|
||||
$0.clipsToBounds = true
|
||||
$0.isUserInteractionEnabled = true
|
||||
$0.snp.makeConstraints { make in make.height.equalTo(160) }
|
||||
}
|
||||
frontImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickFront)))
|
||||
backImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickBack)))
|
||||
|
||||
startDatePicker.datePickerMode = .date
|
||||
endDatePicker.datePickerMode = .date
|
||||
if #available(iOS 17.0, *) {
|
||||
startDatePicker.preferredDatePickerStyle = .compact
|
||||
endDatePicker.preferredDatePickerStyle = .compact
|
||||
}
|
||||
|
||||
let sendCodeButton = UIButton(type: .system)
|
||||
sendCodeButton.setTitle("获取验证码", for: .normal)
|
||||
sendCodeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside)
|
||||
|
||||
let submitButton = makePrimaryButton(title: "下一步")
|
||||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||
|
||||
statusLabel.numberOfLines = 0
|
||||
statusLabel.font = .systemFont(ofSize: 13)
|
||||
statusLabel.textColor = AppDesignUIKit.textSecondary
|
||||
|
||||
contentStack.addArrangedSubview(makeSectionTitle("审核状态"))
|
||||
contentStack.addArrangedSubview(statusLabel)
|
||||
contentStack.addArrangedSubview(makeSectionTitle("身份证国徽面"))
|
||||
contentStack.addArrangedSubview(backImageView)
|
||||
contentStack.addArrangedSubview(makeSectionTitle("身份证人像面"))
|
||||
contentStack.addArrangedSubview(frontImageView)
|
||||
contentStack.addArrangedSubview(labeledField("姓名", realNameField))
|
||||
contentStack.addArrangedSubview(labeledField("身份证号码", idCardField))
|
||||
|
||||
let longValidRow = UIStackView(arrangedSubviews: [UILabel(text: "长期有效"), longValidSwitch])
|
||||
longValidRow.axis = .horizontal
|
||||
longValidSwitch.addTarget(self, action: #selector(longValidChanged), for: .valueChanged)
|
||||
contentStack.addArrangedSubview(longValidRow)
|
||||
contentStack.addArrangedSubview(startDatePicker)
|
||||
contentStack.addArrangedSubview(endDatePicker)
|
||||
|
||||
let smsRow = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton])
|
||||
smsRow.axis = .horizontal
|
||||
smsRow.spacing = 8
|
||||
smsCodeField.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
contentStack.addArrangedSubview(smsRow)
|
||||
contentStack.addArrangedSubview(submitButton)
|
||||
}
|
||||
|
||||
private func makeSectionTitle(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
return label
|
||||
}
|
||||
|
||||
private func labeledField(_ title: String, _ field: UITextField) -> UIStackView {
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
let stack = UIStackView(arrangedSubviews: [label, field])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
field.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
return stack
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
realNameField.text = viewModel.realName
|
||||
idCardField.text = viewModel.idCardNo
|
||||
smsCodeField.text = viewModel.smsCode
|
||||
longValidSwitch.isOn = viewModel.isLongValid
|
||||
startDatePicker.date = viewModel.startDate
|
||||
endDatePicker.date = viewModel.endDate
|
||||
endDatePicker.isEnabled = !viewModel.isLongValid
|
||||
|
||||
if let data = viewModel.pendingFrontImageData, let image = UIImage(data: data) {
|
||||
frontImageView.image = image
|
||||
} else if !viewModel.frontUrl.isEmpty {
|
||||
frontImageView.loadRemoteImage(urlString: viewModel.frontUrl, contentMode: .scaleAspectFit)
|
||||
}
|
||||
|
||||
if let data = viewModel.pendingBackImageData, let image = UIImage(data: data) {
|
||||
backImageView.image = image
|
||||
} else if !viewModel.backUrl.isEmpty {
|
||||
backImageView.image = nil
|
||||
backImageView.loadRemoteImage(urlString: viewModel.backUrl, contentMode: .scaleAspectFit)
|
||||
}
|
||||
|
||||
if let info = viewModel.info {
|
||||
statusLabel.text = """
|
||||
审核状态:\(viewModel.auditStatusText(info.auditStatus))
|
||||
审核人:\(info.auditor?.name ?? "--")
|
||||
审核时间:\(info.auditAt ?? "--")
|
||||
审核备注:\(info.rejectReason ?? "--")
|
||||
"""
|
||||
} else {
|
||||
statusLabel.text = "尚未提交实名认证"
|
||||
}
|
||||
|
||||
if let message = viewModel.statusMessage {
|
||||
showToast(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadInfo() async {
|
||||
do {
|
||||
try await appServices.globalLoading.withLoading(message: "加载中...") {
|
||||
try await self.viewModel.load(api: self.appServices.profileAPI)
|
||||
}
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func longValidChanged() {
|
||||
viewModel.isLongValid = longValidSwitch.isOn
|
||||
}
|
||||
|
||||
@objc private func sendCodeTapped() {
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.sendCode(api: appServices.profileAPI)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func submitTapped() {
|
||||
viewModel.realName = realNameField.text ?? ""
|
||||
viewModel.idCardNo = idCardField.text ?? ""
|
||||
viewModel.smsCode = smsCodeField.text ?? ""
|
||||
viewModel.startDate = startDatePicker.date
|
||||
viewModel.endDate = endDatePicker.date
|
||||
|
||||
guard let scenicId = appServices.accountContext.currentScenic?.id else {
|
||||
showToast("请先选择景区")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await appServices.globalLoading.withLoading(message: "提交中...") {
|
||||
try await self.viewModel.submit(
|
||||
api: self.appServices.profileAPI,
|
||||
uploader: self.appServices.ossUploadService,
|
||||
scenicId: scenicId
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pendingSide: RealNameImageSide = .front
|
||||
|
||||
@objc private func pickFront() {
|
||||
pendingSide = .front
|
||||
presentImagePicker()
|
||||
}
|
||||
|
||||
@objc private func pickBack() {
|
||||
pendingSide = .back
|
||||
presentImagePicker()
|
||||
}
|
||||
|
||||
private func presentImagePicker() {
|
||||
var config = PHPickerConfiguration()
|
||||
config.filter = .images
|
||||
config.selectionLimit = 1
|
||||
let picker = PHPickerViewController(configuration: config)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension RealNameAuthViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||||
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
||||
DispatchQueue.main.async {
|
||||
do {
|
||||
try self.viewModel.prepareIdentityImage(data: data, side: self.pendingSide)
|
||||
} catch {
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UILabel {
|
||||
convenience init(text: String) {
|
||||
self.init()
|
||||
self.text = text
|
||||
self.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
//
|
||||
// SettingsViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
/// 设置中心页面。
|
||||
final class SettingsViewController: UIViewController {
|
||||
|
||||
private var copiedDownloadLink = false
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
private let rows: [(title: String, action: SettingsRowAction)] = [
|
||||
("关于我们", .agreement(.about)),
|
||||
("系统版本", .version),
|
||||
("App下载", .download),
|
||||
("用户协议", .agreement(.userAgreement)),
|
||||
("隐私政策", .agreement(.privacyPolicy))
|
||||
]
|
||||
|
||||
private enum SettingsRowAction {
|
||||
case agreement(AgreementPage)
|
||||
case version
|
||||
case download
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "设置"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
}
|
||||
|
||||
private var downloadLink: String {
|
||||
APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString
|
||||
}
|
||||
|
||||
private func copyDownloadLink() {
|
||||
UIPasteboard.general.string = downloadLink
|
||||
copiedDownloadLink = true
|
||||
showToast("下载链接已复制")
|
||||
tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||||
self?.copiedDownloadLink = false
|
||||
self?.tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SettingsViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count }
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
let row = rows[indexPath.row]
|
||||
cell.textLabel?.text = row.title
|
||||
cell.textLabel?.textColor = UIColor(hex: 0x4B5563)
|
||||
switch row.action {
|
||||
case .version:
|
||||
cell.detailTextLabel?.text = SettingsDisplayPolicy.versionText()
|
||||
cell.selectionStyle = .none
|
||||
case .download:
|
||||
cell.detailTextLabel?.text = copiedDownloadLink ? "已复制" : "复制链接"
|
||||
cell.detailTextLabel?.textColor = AppDesignUIKit.primary
|
||||
case .agreement:
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
switch rows[indexPath.row].action {
|
||||
case .agreement(let page):
|
||||
HomeMenuRouting.pushProfile(.agreement(page), from: self)
|
||||
case .download:
|
||||
copyDownloadLink()
|
||||
case .version:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||
"Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
|
||||
}
|
||||
}
|
||||
|
||||
/// 协议 H5 页面。
|
||||
final class AgreementViewController: UIViewController {
|
||||
|
||||
private let page: AgreementPage
|
||||
private let webView = WKWebView(frame: .zero)
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .large)
|
||||
|
||||
init(page: AgreementPage) {
|
||||
self.page = page
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = page.title
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
webView.navigationDelegate = self
|
||||
view.addSubview(webView)
|
||||
view.addSubview(loadingIndicator)
|
||||
webView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
loadingIndicator.snp.makeConstraints { make in make.center.equalToSuperview() }
|
||||
loadingIndicator.startAnimating()
|
||||
webView.load(URLRequest(url: page.url))
|
||||
}
|
||||
}
|
||||
|
||||
extension AgreementViewController: WKNavigationDelegate {
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
loadingIndicator.stopAnimating()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
loadingIndicator.stopAnimating()
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
//
|
||||
// AccountSwitchViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 账号切换 ViewModel,管理可切换账号列表、选择状态和提交状态。
|
||||
final class AccountSwitchViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var accounts: [AccountSwitchAccount] = [] { didSet { onChange?() } }
|
||||
var selectedAccountId: String? { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var switching = false { didSet { onChange?() } }
|
||||
private var didLoad = false
|
||||
|
||||
/// 当前选中的账号。
|
||||
var selectedAccount: AccountSwitchAccount? {
|
||||
accounts.first { $0.id == selectedAccountId }
|
||||
}
|
||||
|
||||
/// 拉取可切换账号列表,默认只加载一次。
|
||||
func load(api: ProfileAPI, force: Bool = false, currentAccountId: String? = nil) async throws {
|
||||
guard force || !didLoad else { return }
|
||||
didLoad = true
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
|
||||
let response = try await api.switchableAccounts()
|
||||
accounts = response.accounts.filter { $0.businessUserId > 0 }
|
||||
selectedAccountId = accounts.first(where: { $0.isCurrent || isCurrent($0, currentAccountId: currentAccountId) })?.id
|
||||
?? accounts.first?.id
|
||||
}
|
||||
|
||||
/// 选择指定账号。
|
||||
func select(_ account: AccountSwitchAccount) {
|
||||
selectedAccountId = account.id
|
||||
}
|
||||
|
||||
/// 提交账号切换,调用 set-user 换取正式 token。
|
||||
func switchAccount(_ account: AccountSwitchAccount, api: AuthAPI) async throws -> V9AuthResponse {
|
||||
guard !switching else {
|
||||
throw AccountSwitchError.submitting
|
||||
}
|
||||
guard account.businessUserId > 0 else {
|
||||
throw AccountSwitchError.invalidAccount
|
||||
}
|
||||
|
||||
switching = true
|
||||
defer { switching = false }
|
||||
return try await api.setUser(account.toSetUserRequest())
|
||||
}
|
||||
|
||||
/// 判断账号是否与当前账号标识一致。
|
||||
func isCurrent(_ account: AccountSwitchAccount, currentAccountId: String?) -> Bool {
|
||||
guard let currentAccountId else { return false }
|
||||
return account.id == currentAccountId || String(account.businessUserId) == currentAccountId
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号切换错误实体,表示重复提交或账号数据异常。
|
||||
enum AccountSwitchError: LocalizedError, Equatable {
|
||||
case submitting
|
||||
case invalidAccount
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .submitting:
|
||||
"账号正在切换,请稍候"
|
||||
case .invalidAccount:
|
||||
"账号信息异常,请重新选择账号"
|
||||
}
|
||||
}
|
||||
}
|
||||
200
suixinkan_ios/Features/Profile/ViewModels/ProfileViewModel.swift
Normal file
200
suixinkan_ios/Features/Profile/ViewModels/ProfileViewModel.swift
Normal file
@ -0,0 +1,200 @@
|
||||
//
|
||||
// ProfileViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 个人信息页 ViewModel,负责资料加载、编辑状态和提交更新。
|
||||
final class ProfileViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var userInfo: UserInfoResponse? { didSet { onChange?() } }
|
||||
var realNameInfo: RealNameInfo? { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isSaving = false { didSet { onChange?() } }
|
||||
var isEditingProfile = false { didSet { onChange?() } }
|
||||
var editingNickname = "" { didSet { onChange?() } }
|
||||
private(set) var pendingAvatarData: Data? { didSet { onChange?() } }
|
||||
private(set) var pendingAvatarFileName: String? { didSet { onChange?() } }
|
||||
private(set) var avatarUploadProgress: Int? { didSet { onChange?() } }
|
||||
|
||||
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
|
||||
clearPendingAvatar()
|
||||
}
|
||||
|
||||
/// 准备待上传头像,并进入资料编辑状态。
|
||||
func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
|
||||
let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp)
|
||||
pendingAvatarData = processed.data
|
||||
pendingAvatarFileName = processed.fileName
|
||||
if !isEditingProfile {
|
||||
beginEditing()
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存昵称和头像修改,并同步更新本地 userInfo。
|
||||
func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !nextNickname.isEmpty else {
|
||||
throw ProfileValidationError.emptyNickname
|
||||
}
|
||||
|
||||
let nicknameChanged = nextNickname != displayNickname
|
||||
let avatarChanged = pendingAvatarData != nil
|
||||
guard nicknameChanged || avatarChanged else {
|
||||
cancelEditing()
|
||||
return
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
avatarUploadProgress = avatarChanged ? 1 : nil
|
||||
defer {
|
||||
isSaving = false
|
||||
avatarUploadProgress = nil
|
||||
}
|
||||
|
||||
let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId)
|
||||
if let uploadedAvatarURL {
|
||||
try await api.updateUserAvatarURL(uploadedAvatarURL)
|
||||
}
|
||||
if nicknameChanged {
|
||||
try await api.updateUserInfo(nickname: nextNickname)
|
||||
}
|
||||
var nextUser = userInfo ?? UserInfoResponse()
|
||||
nextUser = UserInfoResponse(
|
||||
avatar: uploadedAvatarURL ?? nextUser.avatar,
|
||||
realName: nextUser.realName,
|
||||
phone: nextUser.phone,
|
||||
nickname: nextNickname,
|
||||
roleName: nextUser.roleName,
|
||||
status: nextUser.status,
|
||||
statusName: nextUser.statusName
|
||||
)
|
||||
userInfo = nextUser
|
||||
clearPendingAvatar()
|
||||
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
|
||||
}
|
||||
|
||||
/// 上传待保存头像;没有待上传图片时返回 nil。
|
||||
private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? {
|
||||
guard let pendingAvatarData else { return nil }
|
||||
let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg"
|
||||
return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.avatarUploadProgress = progress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空本地待上传头像数据。
|
||||
private func clearPendingAvatar() {
|
||||
pendingAvatarData = nil
|
||||
pendingAvatarFileName = nil
|
||||
avatarUploadProgress = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人信息页校验错误实体,表示昵称和密码输入不符合要求。
|
||||
enum ProfileValidationError: LocalizedError {
|
||||
case emptyNickname
|
||||
case shortPassword
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyNickname:
|
||||
"请输入昵称"
|
||||
case .shortPassword:
|
||||
"密码至少 6 位"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,230 @@
|
||||
//
|
||||
// RealNameAuthViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 实名认证 ViewModel,管理认证资料表单、审核状态、短信验证码和提交状态。
|
||||
final class RealNameAuthViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var info: RealNameInfo? { didSet { onChange?() } }
|
||||
var realName = "" { didSet { onChange?() } }
|
||||
var idCardNo = "" { didSet { onChange?() } }
|
||||
var smsCode = "" { didSet { onChange?() } }
|
||||
var startDate = Date() { didSet { onChange?() } }
|
||||
var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date() { didSet { onChange?() } }
|
||||
var isLongValid = false { didSet { onChange?() } }
|
||||
var frontUrl = "" { didSet { onChange?() } }
|
||||
var backUrl = "" { didSet { onChange?() } }
|
||||
private(set) var pendingFrontImageData: Data? { didSet { onChange?() } }
|
||||
private(set) var pendingBackImageData: Data? { didSet { onChange?() } }
|
||||
private(set) var frontUploadProgress: Int? { didSet { onChange?() } }
|
||||
private(set) var backUploadProgress: Int? { didSet { onChange?() } }
|
||||
private var pendingFrontFileName: String?
|
||||
private var pendingBackFileName: String?
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var sendingCode = false { didSet { onChange?() } }
|
||||
private(set) var submitting = false { didSet { onChange?() } }
|
||||
var statusMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 拉取实名认证信息,并回填表单。
|
||||
func load(api: ProfileAPI) async throws {
|
||||
guard !loading else { return }
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
|
||||
let response = try await api.realNameInfo()
|
||||
apply(response.realNameInfo)
|
||||
}
|
||||
|
||||
/// 发送短信验证码。
|
||||
func sendCode(api: ProfileAPI) async throws {
|
||||
guard !sendingCode else { return }
|
||||
sendingCode = true
|
||||
defer { sendingCode = false }
|
||||
|
||||
try await api.realNameSmsVerifyCode()
|
||||
statusMessage = "验证码已发送"
|
||||
}
|
||||
|
||||
/// 准备待上传证件图片,并更新对应面的本地预览。
|
||||
func prepareIdentityImage(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
|
||||
let processed = try RealNameImageProcessor.process(data: data, side: side, timestamp: timestamp)
|
||||
switch side {
|
||||
case .front:
|
||||
pendingFrontImageData = processed.data
|
||||
pendingFrontFileName = processed.fileName
|
||||
frontUrl = ""
|
||||
case .back:
|
||||
pendingBackImageData = processed.data
|
||||
pendingBackFileName = processed.fileName
|
||||
backUrl = ""
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交实名认证资料,成功后刷新审核状态。
|
||||
func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
if info?.verified == true {
|
||||
statusMessage = "已完成实名认证"
|
||||
return
|
||||
}
|
||||
if let validationMessage {
|
||||
throw RealNameValidationError.message(validationMessage)
|
||||
}
|
||||
|
||||
submitting = true
|
||||
defer { submitting = false }
|
||||
try await uploadPendingIdentityImages(uploader: uploader, scenicId: scenicId)
|
||||
try await api.realNameSubmit(makeRequest())
|
||||
statusMessage = "已提交审核"
|
||||
try await load(api: api)
|
||||
}
|
||||
|
||||
/// 根据审核状态生成展示文案。
|
||||
func auditStatusText(_ status: Int) -> String {
|
||||
switch status {
|
||||
case 1: "待审核"
|
||||
case 2: "审核通过"
|
||||
case 3: "审核不通过"
|
||||
default: "未认证"
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前表单校验错误,nil 表示可以提交。
|
||||
var validationMessage: String? {
|
||||
if realName.trimmed.isEmpty { return "请输入真实姓名" }
|
||||
if idCardNo.trimmed.isEmpty { return "请输入身份证号" }
|
||||
if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" }
|
||||
if info?.verified != true {
|
||||
if smsCode.trimmed.isEmpty { return "请输入短信验证码" }
|
||||
if frontUrl.trimmed.isEmpty && pendingFrontImageData == nil { return "请选择身份证人像面图片" }
|
||||
if backUrl.trimmed.isEmpty && pendingBackImageData == nil { return "请选择身份证国徽面图片" }
|
||||
}
|
||||
if !isLongValid && endDate < startDate { return "证件结束日期不能早于起始日期" }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 应用接口返回的实名认证信息。
|
||||
private func apply(_ info: RealNameInfo?) {
|
||||
self.info = info
|
||||
guard let info else { return }
|
||||
realName = info.realName
|
||||
idCardNo = info.idCardNo
|
||||
frontUrl = info.frontUrl ?? ""
|
||||
backUrl = info.backUrl ?? ""
|
||||
pendingFrontImageData = nil
|
||||
pendingBackImageData = nil
|
||||
pendingFrontFileName = nil
|
||||
pendingBackFileName = nil
|
||||
isLongValid = info.isLongValid
|
||||
startDate = Self.date(from: info.startDate) ?? startDate
|
||||
endDate = Self.date(from: info.endDate) ?? endDate
|
||||
}
|
||||
|
||||
/// 生成提交请求。
|
||||
private func makeRequest() -> RealNameAuthRequest {
|
||||
RealNameAuthRequest(
|
||||
realName: realName.trimmed,
|
||||
idCardNo: Self.normalizedIDCardNumber(idCardNo),
|
||||
smsVerifyCode: smsCode.trimmed,
|
||||
startDate: Self.dateFormatter.string(from: startDate),
|
||||
endDate: isLongValid ? "" : Self.dateFormatter.string(from: endDate),
|
||||
isLongValid: isLongValid ? 1 : 0,
|
||||
frontUrl: frontUrl.trimmed,
|
||||
backUrl: backUrl.trimmed
|
||||
)
|
||||
}
|
||||
|
||||
/// 规范化身份证号,去除空白并大写末位 X。
|
||||
nonisolated static func normalizedIDCardNumber(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
}
|
||||
|
||||
/// 校验大陆身份证号基础格式和校验位。
|
||||
nonisolated static func isValidMainlandIDCardNumber(_ value: String) -> Bool {
|
||||
let number = normalizedIDCardNumber(value)
|
||||
guard number.count == 18 else { return false }
|
||||
let chars = Array(number)
|
||||
guard chars.prefix(17).allSatisfy(\.isNumber) else { return false }
|
||||
guard chars[17].isNumber || chars[17] == "X" else { return false }
|
||||
|
||||
let weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
let checkCodes = Array("10X98765432")
|
||||
let sum = zip(chars.prefix(17), weights).reduce(0) { partial, pair in
|
||||
partial + (pair.0.wholeNumberValue ?? 0) * pair.1
|
||||
}
|
||||
return chars[17] == checkCodes[sum % 11]
|
||||
}
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// 将 yyyy-MM-dd 文本解析为日期。
|
||||
private static func date(from value: String?) -> Date? {
|
||||
guard let value, !value.isEmpty else { return nil }
|
||||
return dateFormatter.date(from: value)
|
||||
}
|
||||
|
||||
/// 上传待提交证件图片,并把返回 URL 写回表单字段。
|
||||
private func uploadPendingIdentityImages(uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
if let pendingFrontImageData {
|
||||
frontUploadProgress = 1
|
||||
defer { frontUploadProgress = nil }
|
||||
frontUrl = try await uploader.uploadRealNameImage(
|
||||
data: pendingFrontImageData,
|
||||
fileName: pendingFrontFileName ?? "real_name_front_\(Int(Date().timeIntervalSince1970)).jpg",
|
||||
scenicId: scenicId
|
||||
) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.frontUploadProgress = progress
|
||||
}
|
||||
}
|
||||
self.pendingFrontImageData = nil
|
||||
pendingFrontFileName = nil
|
||||
}
|
||||
|
||||
if let pendingBackImageData {
|
||||
backUploadProgress = 1
|
||||
defer { backUploadProgress = nil }
|
||||
backUrl = try await uploader.uploadRealNameImage(
|
||||
data: pendingBackImageData,
|
||||
fileName: pendingBackFileName ?? "real_name_back_\(Int(Date().timeIntervalSince1970)).jpg",
|
||||
scenicId: scenicId
|
||||
) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.backUploadProgress = progress
|
||||
}
|
||||
}
|
||||
self.pendingBackImageData = nil
|
||||
pendingBackFileName = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证校验错误实体,用于把表单错误统一抛给页面展示。
|
||||
enum RealNameValidationError: LocalizedError, Equatable {
|
||||
case message(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .message(let message):
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// 去除首尾空白后的文本。
|
||||
var trimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user