Sync migrated iOS modules
This commit is contained in:
@ -29,6 +29,16 @@ final class ProfileAPI {
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取当前登录账号可切换的景区账号和门店账号。
|
||||
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(
|
||||
@ -61,6 +71,28 @@ final class ProfileAPI {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 发送实名认证短信验证码。
|
||||
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 {}
|
||||
|
||||
@ -80,25 +80,100 @@ struct RealNameInfoResponse: Decodable, Equatable {
|
||||
/// 实名认证信息实体,表示当前用户实名审核状态和失败原因。
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 模块职责
|
||||
|
||||
Profile 模块负责“我的/个人信息”页面,包括用户资料展示、昵称编辑、密码修改、实名认证状态展示和退出登录。
|
||||
Profile 模块负责“我的/个人信息”页面及其二级页面,包括用户资料展示、昵称编辑、账号切换、密码修改、实名认证、系统设置、协议页和退出登录。
|
||||
|
||||
该模块聚焦个人资料业务:
|
||||
- 拉取用户基础资料。
|
||||
@ -10,6 +10,9 @@ Profile 模块负责“我的/个人信息”页面,包括用户资料展示
|
||||
- 展示头像、昵称、UID、手机号、账号状态和实名认证状态。
|
||||
- 修改昵称。
|
||||
- 修改密码。
|
||||
- 切换当前景区/门店业务账号。
|
||||
- 提交实名认证基础资料。
|
||||
- 展示系统设置、版本号、下载链接和协议 H5 页面。
|
||||
- 退出登录入口。
|
||||
|
||||
登录态清理和缓存清理由 App 模块的 `AuthSessionCoordinator` 统一处理。
|
||||
@ -18,10 +21,15 @@ Profile 模块负责“我的/个人信息”页面,包括用户资料展示
|
||||
|
||||
- `ProfileView`:个人信息页 UI,负责展示资料、编辑入口、密码弹窗和退出确认。
|
||||
- `ProfileViewModel`:维护资料加载状态、编辑状态、保存状态和展示文案。
|
||||
- `ProfileRoute`:个人中心二级页面路由。
|
||||
- `AccountSwitchView` / `AccountSwitchViewModel`:账号切换页面和状态逻辑。
|
||||
- `RealNameAuthView` / `RealNameAuthViewModel`:实名认证页面和表单逻辑。
|
||||
- `SettingsCenterView` / `AgreementView`:设置中心和协议 H5 页面。
|
||||
- `ProfileAPI`:封装用户资料、实名认证和资料更新接口。
|
||||
- `UserInfoResponse`:用户基础资料。
|
||||
- `RealNameInfoResponse` / `RealNameInfo`:实名认证信息。
|
||||
- `UpdateInfoRequest`:昵称、密码或头像更新请求体。
|
||||
- `RealNameAuthRequest`:实名认证提交请求体。
|
||||
|
||||
## 加载流程
|
||||
|
||||
@ -54,6 +62,33 @@ Profile 模块负责“我的/个人信息”页面,包括用户资料展示
|
||||
|
||||
密码不会写入本地缓存。
|
||||
|
||||
## 账号切换流程
|
||||
|
||||
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. 提交前校验姓名、身份证号、短信验证码、证件图片 URL 和有效期。
|
||||
5. 校验通过后调用 `/api/yf-handset-app/photog/real-name/submit`。
|
||||
6. 提交成功后重新加载实名认证信息。
|
||||
|
||||
本轮证件图片先保留 URL 输入和网络预览,阿里云 OSS 图片选择上传后续接入时替换该输入区。
|
||||
|
||||
## 设置和协议流程
|
||||
|
||||
1. 用户点击“系统设置”进入 `SettingsCenterView`。
|
||||
2. 设置中心展示关于我们、系统版本、App 下载链接、用户协议和隐私政策。
|
||||
3. App 下载点击后复制 `/h5/app/download` 链接到剪贴板。
|
||||
4. 协议页使用 `AgreementView` 加载 `APIEnvironment.current.baseURL` 下的 H5 页面。
|
||||
|
||||
## 退出登录流程
|
||||
|
||||
1. 用户点击退出登录。
|
||||
@ -72,4 +107,5 @@ Profile 模块负责“我的/个人信息”页面,包括用户资料展示
|
||||
- `auditStatus == 3`:审核不通过。
|
||||
- 其他状态:审核中。
|
||||
- 无实名认证信息时:点击去实名认证。
|
||||
|
||||
- 账号切换失败只展示 Toast,不清空登录态。
|
||||
- 实名认证失败只影响实名认证页面,不影响登录态和账号上下文。
|
||||
|
||||
31
suixinkan/Features/Profile/Routing/ProfileRoute.swift
Normal file
31
suixinkan/Features/Profile/Routing/ProfileRoute.swift
Normal file
@ -0,0 +1,31 @@
|
||||
//
|
||||
// ProfileRoute.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 个人中心二级页面路由,集中声明“我的”Tab 可以 push 的页面。
|
||||
enum ProfileRoute: Hashable {
|
||||
case accountSwitch
|
||||
case realNameAuth
|
||||
case settings
|
||||
case agreement(AgreementPage)
|
||||
|
||||
/// 构建个人中心路由对应的 SwiftUI 目标页面。
|
||||
@ViewBuilder
|
||||
var destinationView: some View {
|
||||
switch self {
|
||||
case .accountSwitch:
|
||||
AccountSwitchView()
|
||||
case .realNameAuth:
|
||||
RealNameAuthView()
|
||||
case .settings:
|
||||
SettingsCenterView()
|
||||
case .agreement(let page):
|
||||
AgreementView(page: page)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
//
|
||||
// AccountSwitchViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 账号切换 ViewModel,管理可切换账号列表、选择状态和提交状态。
|
||||
final class AccountSwitchViewModel {
|
||||
private(set) var accounts: [AccountSwitchAccount] = []
|
||||
var selectedAccountId: String?
|
||||
private(set) var loading = false
|
||||
private(set) var switching = false
|
||||
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:
|
||||
"账号信息异常,请重新选择账号"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
//
|
||||
// RealNameAuthViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 实名认证 ViewModel,管理认证资料表单、审核状态、短信验证码和提交状态。
|
||||
final class RealNameAuthViewModel {
|
||||
private(set) var info: RealNameInfo?
|
||||
var realName = ""
|
||||
var idCardNo = ""
|
||||
var smsCode = ""
|
||||
var startDate = Date()
|
||||
var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date()
|
||||
var isLongValid = false
|
||||
var frontUrl = ""
|
||||
var backUrl = ""
|
||||
private(set) var loading = false
|
||||
private(set) var sendingCode = false
|
||||
private(set) var submitting = false
|
||||
var statusMessage: String?
|
||||
|
||||
/// 拉取实名认证信息,并回填表单。
|
||||
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 submit(api: ProfileAPI) async throws {
|
||||
if info?.verified == true {
|
||||
statusMessage = "已完成实名认证"
|
||||
return
|
||||
}
|
||||
if let validationMessage {
|
||||
throw RealNameValidationError.message(validationMessage)
|
||||
}
|
||||
|
||||
submitting = true
|
||||
defer { submitting = false }
|
||||
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 { return "请填写身份证人像面图片 URL" }
|
||||
if backUrl.trimmed.isEmpty { return "请填写身份证国徽面图片 URL" }
|
||||
}
|
||||
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 ?? ""
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证校验错误实体,用于把表单错误统一抛给页面展示。
|
||||
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)
|
||||
}
|
||||
}
|
||||
260
suixinkan/Features/Profile/Views/AccountSwitchView.swift
Normal file
260
suixinkan/Features/Profile/Views/AccountSwitchView.swift
Normal file
@ -0,0 +1,260 @@
|
||||
//
|
||||
// AccountSwitchView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。
|
||||
struct AccountSwitchView: View {
|
||||
@Environment(AppSession.self) private var appSession
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(AuthAPI.self) private var authAPI
|
||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = AccountSwitchViewModel()
|
||||
|
||||
private var selectedAccount: AccountSwitchAccount? {
|
||||
viewModel.selectedAccount
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
accountList
|
||||
bottomBar
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FB).ignoresSafeArea())
|
||||
.navigationTitle("账号切换")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await loadAccounts()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var accountList: some View {
|
||||
if viewModel.accounts.isEmpty && !viewModel.loading {
|
||||
ContentUnavailableView(
|
||||
"暂无可切换账号",
|
||||
systemImage: "person.crop.circle.badge.exclamationmark",
|
||||
description: Text("当前登录账号下没有其他可切换账号。")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(24)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(viewModel.accounts) { account in
|
||||
accountCard(account)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 92)
|
||||
}
|
||||
.refreshable {
|
||||
await loadAccounts(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
Button {
|
||||
Task { await confirmSelection() }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if viewModel.switching {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
Text("确认切换")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(canConfirm ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canConfirm)
|
||||
}
|
||||
.background(.white)
|
||||
}
|
||||
|
||||
private var canConfirm: Bool {
|
||||
selectedAccount != nil && !viewModel.loading && !viewModel.switching
|
||||
}
|
||||
|
||||
/// 构造单个账号卡片。
|
||||
private func accountCard(_ account: AccountSwitchAccount) -> some View {
|
||||
let selected = viewModel.selectedAccountId == account.id
|
||||
return Button {
|
||||
viewModel.select(account)
|
||||
} label: {
|
||||
HStack(spacing: 13) {
|
||||
accountAvatar(account)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 8) {
|
||||
Text(nonEmpty(account.title) ?? account.accountTypeLabel)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
if account.isCurrent || isCurrentAccount(account) {
|
||||
tag("当前", foreground: AppDesign.primary, background: AppDesign.primarySoft)
|
||||
}
|
||||
}
|
||||
|
||||
if !account.subtitle.isEmpty {
|
||||
Text(account.subtitle)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if !account.phone.isEmpty {
|
||||
Text(account.phone)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Color(hex: 0x9AA1AA))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
tag(
|
||||
account.isStoreUser ? "门店" : "景区",
|
||||
foreground: account.isStoreUser ? Color(hex: 0x0F9F6E) : Color(hex: 0x7C3AED),
|
||||
background: account.isStoreUser ? Color(hex: 0xE8F8F1) : Color(hex: 0xF3ECFF)
|
||||
)
|
||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(selected ? AppDesign.primary : Color(hex: 0xB6BECA))
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(selected ? AppDesign.primary : Color(hex: 0xE6ECF4), lineWidth: selected ? 1.4 : 1)
|
||||
}
|
||||
.shadow(color: Color(hex: 0xC8D7EA, alpha: selected ? 0.24 : 0.12), radius: selected ? 10 : 6, x: 0, y: 5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
/// 构造账号头像,网络图加载失败时显示账号类型首字。
|
||||
private func accountAvatar(_ account: AccountSwitchAccount) -> some View {
|
||||
if let url = URL(string: account.avatar), !account.avatar.isEmpty {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
default:
|
||||
avatarFallback(account)
|
||||
}
|
||||
}
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
avatarFallback(account)
|
||||
.frame(width: 50, height: 50)
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造账号头像占位。
|
||||
private func avatarFallback(_ account: AccountSwitchAccount) -> some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(account.isStoreUser ? Color(hex: 0xE8F8F1) : Color(hex: 0xF3ECFF))
|
||||
Text(account.isStoreUser ? "店" : "景")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(account.isStoreUser ? Color(hex: 0x0F9F6E) : Color(hex: 0x7C3AED))
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造账号标签。
|
||||
private func tag(_ text: String, foreground: Color, background: Color) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(foreground)
|
||||
.padding(.horizontal, 7)
|
||||
.frame(height: 22)
|
||||
.background(background, in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
|
||||
/// 拉取账号列表。
|
||||
private func loadAccounts(force: Bool = false) async {
|
||||
do {
|
||||
try await viewModel.load(api: profileAPI, force: force, currentAccountId: currentAccountId)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 确认账号切换,并刷新全局登录上下文。
|
||||
private func confirmSelection() async {
|
||||
guard let account = selectedAccount else { return }
|
||||
if isCurrentAccount(account) {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await viewModel.switchAccount(account, api: authAPI)
|
||||
let username = nonEmpty(accountContext.profile?.phone) ?? nonEmpty(account.phone) ?? ""
|
||||
try await authSessionCoordinator.completeLogin(
|
||||
with: response,
|
||||
username: username,
|
||||
privacyAgreementAccepted: authSessionCoordinator.loginPreferences().privacyAgreementAccepted,
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI
|
||||
)
|
||||
appRouter.reset()
|
||||
toastCenter.show("账号已切换")
|
||||
dismiss()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private var currentAccountId: String? {
|
||||
guard let current = accountContext.profile else { return nil }
|
||||
if let store = accountContext.currentStore {
|
||||
return "\(V9StoreUser.accountTypeValue)_\(store.id)"
|
||||
}
|
||||
if let scenic = accountContext.currentScenic {
|
||||
return "\(V9ScenicUser.accountTypeValue)_\(scenic.id)"
|
||||
}
|
||||
return current.userId.isEmpty ? nil : current.userId
|
||||
}
|
||||
|
||||
/// 判断账号是否为当前已选账号。
|
||||
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
|
||||
account.isCurrent || viewModel.isCurrent(account, currentAccountId: currentAccountId)
|
||||
}
|
||||
|
||||
/// 返回去空白后的非空文本。
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@ struct ProfileView: View {
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||
@ -234,7 +235,7 @@ struct ProfileView: View {
|
||||
divider
|
||||
|
||||
Button {
|
||||
toastCenter.show("账号切换待接入")
|
||||
router.navigate(to: .profile(.accountSwitch))
|
||||
} label: {
|
||||
infoRow(title: "当前账号") {
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
@ -269,7 +270,7 @@ struct ProfileView: View {
|
||||
divider
|
||||
|
||||
Button {
|
||||
toastCenter.show("实名认证页面待接入")
|
||||
router.navigate(to: .profile(.realNameAuth))
|
||||
} label: {
|
||||
infoRow(title: "认证状态") {
|
||||
realNameStatusView
|
||||
@ -289,6 +290,17 @@ struct ProfileView: View {
|
||||
infoRow(title: "当前景区") {
|
||||
scenicStatusView
|
||||
}
|
||||
|
||||
divider
|
||||
|
||||
Button {
|
||||
router.navigate(to: .profile(.settings))
|
||||
} label: {
|
||||
infoRow(title: "系统设置") {
|
||||
rowChevron
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
383
suixinkan/Features/Profile/Views/RealNameAuthView.swift
Normal file
383
suixinkan/Features/Profile/Views/RealNameAuthView.swift
Normal file
@ -0,0 +1,383 @@
|
||||
//
|
||||
// RealNameAuthView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 实名认证页面,展示审核状态并允许提交基础实名资料。
|
||||
struct RealNameAuthView: View {
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = RealNameAuthViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
auditStatusPanel
|
||||
identityCard
|
||||
privilegeCard
|
||||
tipsCard
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 34)
|
||||
}
|
||||
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
|
||||
.navigationTitle("实名认证")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await loadInfo()
|
||||
}
|
||||
.overlay {
|
||||
if viewModel.loading && viewModel.info == nil {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.white.opacity(0.35))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var auditStatusPanel: some View {
|
||||
VStack(spacing: 14) {
|
||||
auditRow("审核状态", value: viewModel.info?.auditStatusText?.nonEmpty ?? viewModel.auditStatusText(viewModel.info?.auditStatus ?? 0))
|
||||
auditRow("审核人:", value: viewModel.info?.auditor?.name.nonEmpty ?? "--")
|
||||
auditRow("审核时间:", value: viewModel.info?.auditAt?.nonEmpty ?? "--")
|
||||
auditRow("审核备注:", value: viewModel.info?.rejectReason?.nonEmpty ?? "--")
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 22)
|
||||
.background(Color(hex: 0xEAF4FF), in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
/// 构造审核信息行。
|
||||
private func auditRow(_ title: String, value: String) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x1687D9))
|
||||
Spacer(minLength: 20)
|
||||
Text(value)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(Color(hex: 0x1687D9))
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
|
||||
private var identityCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
identityImageSection(title: "身份证国徽面", url: viewModel.backUrl)
|
||||
identityImageSection(title: "身份证人像面", url: viewModel.frontUrl)
|
||||
formField("姓名", text: realNameBinding, placeholder: "请输入姓名")
|
||||
formField("身份证号码", text: idCardNoBinding, placeholder: "请输入身份证号码")
|
||||
validitySection
|
||||
if shouldShowSmsSection {
|
||||
smsSection
|
||||
}
|
||||
urlField("身份证人像面图片 URL", text: frontURLBinding)
|
||||
urlField("身份证国徽面图片 URL", text: backURLBinding)
|
||||
submitButton
|
||||
statusBanner
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 20)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
/// 构造证件图片预览区域。
|
||||
private func identityImageSection(title: String, url: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x222222))
|
||||
imagePreview(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
private var validitySection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
Text("有效期")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x222222))
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.isLongValid.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: viewModel.isLongValid ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
Text("长期有效")
|
||||
.font(.system(size: 15))
|
||||
}
|
||||
.foregroundStyle(viewModel.isLongValid ? AppDesign.primary : Color(hex: 0x8EA0AF))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
dateField(selection: startDateBinding)
|
||||
dateField(selection: endDateBinding)
|
||||
.disabled(viewModel.isLongValid)
|
||||
.opacity(viewModel.isLongValid ? 0.45 : 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var smsSection: some View {
|
||||
HStack(spacing: 10) {
|
||||
TextField("", text: smsCodeBinding, prompt: Text("请输入短信验证码").foregroundColor(AppDesign.placeholder))
|
||||
.keyboardType(.numberPad)
|
||||
.font(.system(size: 16))
|
||||
.appInputFieldStyle(cornerRadius: 8, minHeight: 48)
|
||||
Button {
|
||||
Task { await sendCode() }
|
||||
} label: {
|
||||
Text(viewModel.sendingCode ? "发送中" : "获取验证码")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(viewModel.sendingCode ? Color(hex: 0x9CA3AF) : .white)
|
||||
.frame(width: 104, height: 48)
|
||||
.background(viewModel.sendingCode ? Color(hex: 0xEEF2F7) : AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.sendingCode)
|
||||
}
|
||||
}
|
||||
|
||||
private var submitButton: some View {
|
||||
Button {
|
||||
Task { await submit() }
|
||||
} label: {
|
||||
Text(viewModel.submitting ? "提交中..." : "下一步")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.background(canSubmit ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canSubmit || viewModel.submitting)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBanner: some View {
|
||||
if let statusMessage = viewModel.statusMessage {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: statusMessageIsSuccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 12))
|
||||
Text(statusMessage)
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
.foregroundStyle(statusMessageIsSuccess ? AppDesign.success : AppDesign.warning)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(minHeight: 34, alignment: .leading)
|
||||
.background((statusMessageIsSuccess ? AppDesign.success : AppDesign.warning).opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
private var privilegeCard: some View {
|
||||
VStack(alignment: .leading, spacing: 22) {
|
||||
Text("实名认证特权")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x222222))
|
||||
privilegeRow(icon: "checkmark.circle.fill", title: "开启接单权限", message: "完成实名认证后可立即接单,实名状态对游客可见")
|
||||
privilegeRow(icon: "person.3.fill", title: "加入营销群", message: "专业摄影师交流群,获取更多流量与技术支持")
|
||||
privilegeRow(icon: "map.fill", title: "景区选择权限", message: "可自主选择运营景区,获得独家运营资源")
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 22)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
/// 构造认证特权行。
|
||||
private func privilegeRow(icon: String, title: String, message: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(AppDesign.primary, in: Circle())
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x333333))
|
||||
Text(message)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Color(hex: 0x777777))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var tipsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("温馨提示:")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x333333))
|
||||
tipText("请确保填写的信息真实有效")
|
||||
tipText("身份信息仅用于实名认证,我们将严格保护您的隐私")
|
||||
tipText("如有疑问请联系客服")
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 22)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
/// 构造温馨提示行。
|
||||
private func tipText(_ text: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("-")
|
||||
Text(text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Color(hex: 0x666666))
|
||||
}
|
||||
|
||||
/// 构造证件图片预览。
|
||||
private func imagePreview(url: String) -> some View {
|
||||
ZStack {
|
||||
if let imageURL = URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)), !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
AsyncImage(url: imageURL) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFit()
|
||||
case .empty:
|
||||
ProgressView().tint(AppDesign.primary)
|
||||
default:
|
||||
placeholderImageContent
|
||||
}
|
||||
}
|
||||
} else {
|
||||
placeholderImageContent
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 166)
|
||||
.background(Color(hex: 0xFAFAFA))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(hex: 0xE0E0E0), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
|
||||
)
|
||||
}
|
||||
|
||||
private var placeholderImageContent: some View {
|
||||
ZStack {
|
||||
Color(hex: 0xF3F6FA)
|
||||
Image(systemName: "photo")
|
||||
.font(.system(size: 26, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x8A94A6))
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造文本输入行。
|
||||
private func formField(_ title: String, text: Binding<String>, placeholder: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x222222))
|
||||
TextField("", text: text, prompt: Text(placeholder).foregroundColor(AppDesign.placeholder))
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(Color(hex: 0x333333))
|
||||
.appInputFieldStyle(cornerRadius: 8, minHeight: 54)
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造 URL 输入行。
|
||||
private func urlField(_ title: String, text: Binding<String>) -> some View {
|
||||
formField(title, text: text, placeholder: "OSS 上传接入前可先填写图片 URL")
|
||||
}
|
||||
|
||||
/// 构造日期选择器。
|
||||
private func dateField(selection: Binding<Date>) -> some View {
|
||||
DatePicker("", selection: selection, displayedComponents: .date)
|
||||
.labelsHidden()
|
||||
.datePickerStyle(.compact)
|
||||
.font(.system(size: 16))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 54)
|
||||
.background(Color(hex: 0xF1F1F1), in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
viewModel.validationMessage == nil && !viewModel.submitting
|
||||
}
|
||||
|
||||
private var shouldShowSmsSection: Bool {
|
||||
!viewModel.smsCode.isEmpty || viewModel.info?.verified != true
|
||||
}
|
||||
|
||||
private var statusMessageIsSuccess: Bool {
|
||||
guard let statusMessage = viewModel.statusMessage else { return false }
|
||||
return statusMessage.contains("成功") || statusMessage.contains("已发送") || statusMessage.contains("已提交")
|
||||
}
|
||||
|
||||
private var realNameBinding: Binding<String> {
|
||||
Binding(get: { viewModel.realName }, set: { viewModel.realName = $0 })
|
||||
}
|
||||
|
||||
private var idCardNoBinding: Binding<String> {
|
||||
Binding(get: { viewModel.idCardNo }, set: { viewModel.idCardNo = $0 })
|
||||
}
|
||||
|
||||
private var smsCodeBinding: Binding<String> {
|
||||
Binding(get: { viewModel.smsCode }, set: { viewModel.smsCode = $0 })
|
||||
}
|
||||
|
||||
private var frontURLBinding: Binding<String> {
|
||||
Binding(get: { viewModel.frontUrl }, set: { viewModel.frontUrl = $0 })
|
||||
}
|
||||
|
||||
private var backURLBinding: Binding<String> {
|
||||
Binding(get: { viewModel.backUrl }, set: { viewModel.backUrl = $0 })
|
||||
}
|
||||
|
||||
private var startDateBinding: Binding<Date> {
|
||||
Binding(get: { viewModel.startDate }, set: { viewModel.startDate = $0 })
|
||||
}
|
||||
|
||||
private var endDateBinding: Binding<Date> {
|
||||
Binding(get: { viewModel.endDate }, set: { viewModel.endDate = $0 })
|
||||
}
|
||||
|
||||
/// 拉取实名认证信息。
|
||||
private func loadInfo() async {
|
||||
do {
|
||||
try await viewModel.load(api: profileAPI)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送验证码。
|
||||
private func sendCode() async {
|
||||
do {
|
||||
try await viewModel.sendCode(api: profileAPI)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交实名资料。
|
||||
private func submit() async {
|
||||
do {
|
||||
try await viewModel.submit(api: profileAPI)
|
||||
} catch {
|
||||
viewModel.statusMessage = error.localizedDescription
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// 去除空白后返回非空字符串。
|
||||
var nonEmpty: String? {
|
||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
246
suixinkan/Features/Profile/Views/SettingsFlowViews.swift
Normal file
246
suixinkan/Features/Profile/Views/SettingsFlowViews.swift
Normal file
@ -0,0 +1,246 @@
|
||||
//
|
||||
// SettingsFlowViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
/// 协议和说明页面枚举,描述设置中心可打开的 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置中心页面,展示关于我们、版本、下载链接和协议入口。
|
||||
struct SettingsCenterView: View {
|
||||
@Environment(RouterPath.self) private var router
|
||||
@State private var copiedDownloadLink = false
|
||||
|
||||
private var versionText: String {
|
||||
SettingsDisplayPolicy.versionText()
|
||||
}
|
||||
|
||||
private var downloadLink: String {
|
||||
APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
settingsGroup {
|
||||
settingsButton(title: "关于我们") {
|
||||
router.navigate(to: .profile(.agreement(.about)))
|
||||
}
|
||||
settingsDivider
|
||||
settingsRow(title: "系统版本", value: versionText)
|
||||
settingsDivider
|
||||
Button {
|
||||
copyDownloadLink()
|
||||
} label: {
|
||||
settingsRow(title: "App下载", value: copiedDownloadLink ? "已复制" : "复制链接", valueColor: AppDesign.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
settingsDivider
|
||||
settingsButton(title: "用户协议") {
|
||||
router.navigate(to: .profile(.agreement(.userAgreement)))
|
||||
}
|
||||
settingsDivider
|
||||
settingsButton(title: "隐私政策") {
|
||||
router.navigate(to: .profile(.agreement(.privacyPolicy)))
|
||||
}
|
||||
}
|
||||
|
||||
footer
|
||||
.padding(.top, 40)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
|
||||
.navigationTitle("设置")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
/// 复制 App 下载链接到系统剪贴板。
|
||||
private func copyDownloadLink() {
|
||||
UIPasteboard.general.string = downloadLink
|
||||
copiedDownloadLink = true
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
copiedDownloadLink = false
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造设置分组容器。
|
||||
private func settingsGroup<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
content()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
/// 构造可点击的设置行。
|
||||
private func settingsButton(title: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
settingsRow(title: title, showsChevron: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
/// 构造设置行。
|
||||
private func settingsRow(
|
||||
title: String,
|
||||
value: String? = nil,
|
||||
valueColor: Color = Color(hex: 0x333333),
|
||||
showsChevron: Bool = false
|
||||
) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Text(title)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(Color(hex: 0x4B5563))
|
||||
Spacer()
|
||||
if let value, !value.isEmpty {
|
||||
Text(value)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(valueColor)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x999999))
|
||||
}
|
||||
}
|
||||
.frame(height: 58)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
/// 设置行分割线。
|
||||
private var settingsDivider: some View {
|
||||
Rectangle()
|
||||
.fill(Color(hex: 0xEEEEEE))
|
||||
.frame(height: 0.5)
|
||||
}
|
||||
|
||||
/// 设置页版权页脚。
|
||||
private var footer: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("Copyright © 2025 All Rights Reserved")
|
||||
Text("苏ICP备2025157647号")
|
||||
}
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(Color(hex: 0xB6BECA))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
/// 协议 H5 页面,使用 WKWebView 加载线上内容。
|
||||
struct AgreementView: View {
|
||||
let page: AgreementPage
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AgreementWebView(url: page.url, isLoading: $isLoading)
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.white.opacity(0.4))
|
||||
}
|
||||
}
|
||||
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
|
||||
.navigationTitle(page.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
/// WKWebView 的 SwiftUI 包装,用于加载协议和说明页面。
|
||||
private struct AgreementWebView: UIViewRepresentable {
|
||||
let url: URL
|
||||
@Binding var isLoading: Bool
|
||||
|
||||
/// 创建 WebView 协调器。
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(isLoading: $isLoading)
|
||||
}
|
||||
|
||||
/// 创建底层 WKWebView。
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
return webView
|
||||
}
|
||||
|
||||
/// 根据 URL 更新 WebView 请求。
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
guard webView.url != url else { return }
|
||||
isLoading = true
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
/// WebView 代理协调器,负责同步加载状态。
|
||||
final class Coordinator: NSObject, WKNavigationDelegate {
|
||||
@Binding var isLoading: Bool
|
||||
|
||||
/// 初始化协调器并绑定加载状态。
|
||||
init(isLoading: Binding<Bool>) {
|
||||
_isLoading = isLoading
|
||||
}
|
||||
|
||||
/// 页面加载成功后关闭 loading。
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
/// 页面加载失败后关闭 loading。
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user