Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

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

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

View 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 ""
}
/// 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,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不清空登录态。
- 实名认证失败只影响实名认证页面,不影响登录态和账号上下文。

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

View File

@ -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 ?? "") + " (当前)"
}
}
}

View File

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

View File

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

View File

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

View File

@ -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:
"账号信息异常,请重新选择账号"
}
}
}

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

View File

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