完善实名认证、钱包与打卡点模块 UI 与业务逻辑。
对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -11,7 +11,7 @@ enum HomeMenuCatalog {
|
||||
/// 全部已知菜单项,对齐 Android `Constants.menuList`。
|
||||
static let allItems: [HomeMenuItem] = [
|
||||
HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "home_menu_space"),
|
||||
HomeMenuItem(uri: "wallet", title: "我的钱包", iconName: "creditcard.fill"),
|
||||
HomeMenuItem(uri: "wallet", title: "我的钱包", iconName: "home_menu_wallet"),
|
||||
HomeMenuItem(uri: "cloud_management", title: "相册云盘", iconName: "icloud.fill"),
|
||||
HomeMenuItem(uri: "asset_management", title: "素材管理", iconName: "home_menu_material"),
|
||||
HomeMenuItem(uri: "task_management", title: "任务管理", iconName: "checklist"),
|
||||
@ -19,7 +19,7 @@ enum HomeMenuCatalog {
|
||||
HomeMenuItem(uri: "schedule_management", title: "日程管理", iconName: "calendar"),
|
||||
HomeMenuItem(uri: "system_settings", title: "设置中心", iconName: "gearshape.fill"),
|
||||
HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell.fill"),
|
||||
HomeMenuItem(uri: "checkin_points", title: "打卡点管理", iconName: "mappin.and.ellipse"),
|
||||
HomeMenuItem(uri: "checkin_points", title: "打卡点管理", iconName: "home_menu_punch_point"),
|
||||
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "sample_ic_home_menu_route"),
|
||||
HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "dot.radiowaves.left.and.right"),
|
||||
HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal.fill"),
|
||||
|
||||
@ -139,8 +139,7 @@ final class ProfileAPI {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/real-name/sms-verify-code",
|
||||
body: EmptyPayload()
|
||||
path: "/api/yf-handset-app/photog/real-name/sms-verify-code"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,59 +5,147 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 实名认证 ViewModel,管理认证资料表单、审核状态、短信验证码和提交状态。
|
||||
/// 实名认证页面模式,描述首次申请与各审核状态下的编辑权限。
|
||||
enum RealNameAuthMode: Equatable {
|
||||
case newApplication
|
||||
case pending
|
||||
case approved
|
||||
case rejected
|
||||
|
||||
/// 页面是否只允许查看资料。
|
||||
var isReadOnly: Bool {
|
||||
self == .pending || self == .approved
|
||||
}
|
||||
|
||||
/// 页面是否允许编辑并提交资料。
|
||||
var isEditable: Bool {
|
||||
!isReadOnly
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证两步表单的当前步骤。
|
||||
enum RealNameAuthStep: Int, Equatable {
|
||||
case identity = 1
|
||||
case verification = 2
|
||||
}
|
||||
|
||||
/// 实名认证 ViewModel,管理两步表单、审核模式、图片上传、验证码和提交状态。
|
||||
final class RealNameAuthViewModel {
|
||||
private(set) var info: RealNameInfo?
|
||||
private(set) var mode: RealNameAuthMode
|
||||
private(set) var currentStep: RealNameAuthStep = .identity
|
||||
private(set) var realName = ""
|
||||
private(set) var idCardNo = ""
|
||||
private(set) var smsCode = ""
|
||||
private(set) var startDate = Date()
|
||||
private(set) var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date()
|
||||
private(set) var startDate: Date?
|
||||
private(set) var endDate: Date?
|
||||
private(set) var isLongValid = false
|
||||
private(set) var isAgreed = false
|
||||
private(set) var frontUrl = ""
|
||||
private(set) var backUrl = ""
|
||||
private(set) var pendingFrontImageData: Data?
|
||||
private(set) var pendingBackImageData: Data?
|
||||
private(set) var frontPreviewImageData: Data?
|
||||
private(set) var backPreviewImageData: Data?
|
||||
private(set) var frontUploadProgress = 0
|
||||
private(set) var backUploadProgress = 0
|
||||
private(set) var isFrontUploading = false
|
||||
private(set) var isBackUploading = false
|
||||
private(set) var loading = false
|
||||
private(set) var sendingCode = false
|
||||
private(set) var submitting = false
|
||||
private(set) var otpCountdown = 0
|
||||
private(set) var statusMessage: String?
|
||||
|
||||
private var pendingFrontFileName: String?
|
||||
private var pendingBackFileName: String?
|
||||
private var countdownTask: Task<Void, Never>?
|
||||
|
||||
/// 页面状态变化后通知 View 主动刷新。
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
/// 使用可选审核信息创建表单;无信息表示首次申请。
|
||||
init(info: RealNameInfo? = nil) {
|
||||
self.info = info
|
||||
mode = Self.mode(for: info)
|
||||
apply(info)
|
||||
}
|
||||
|
||||
deinit {
|
||||
countdownTask?.cancel()
|
||||
}
|
||||
|
||||
/// 当前是否存在任一证件图片上传任务。
|
||||
var isUploading: Bool {
|
||||
isFrontUploading || isBackUploading
|
||||
}
|
||||
|
||||
/// 第一步是否满足进入短信验证步骤的条件。
|
||||
var canGoNextStep: Bool {
|
||||
guard mode.isEditable, currentStep == .identity, !isUploading else { return false }
|
||||
return identityValidationMessage == nil && isAgreed
|
||||
}
|
||||
|
||||
/// 第二步是否满足最终提交条件。
|
||||
var canSubmit: Bool {
|
||||
guard mode.isEditable, currentStep == .verification, !submitting, !isUploading else { return false }
|
||||
return validationMessage == nil
|
||||
}
|
||||
|
||||
/// 验证码按钮是否可点击。
|
||||
var canRequestCode: Bool {
|
||||
mode.isEditable && currentStep == .verification && otpCountdown == 0 && !sendingCode
|
||||
}
|
||||
|
||||
/// 更新真实姓名。
|
||||
func updateRealName(_ value: String) {
|
||||
guard mode.isEditable else { return }
|
||||
realName = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 更新身份证号,只保留数字和 X 并统一为大写。
|
||||
func updateIdCardNo(_ value: String) {
|
||||
idCardNo = value
|
||||
guard mode.isEditable else { return }
|
||||
idCardNo = String(value.filter { $0.isNumber || $0 == "X" || $0 == "x" }.prefix(18)).uppercased()
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 更新短信验证码,只保留最多 11 位数字。
|
||||
func updateSmsCode(_ value: String) {
|
||||
smsCode = value
|
||||
guard mode.isEditable else { return }
|
||||
smsCode = String(value.filter(\.isNumber).prefix(11))
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 切换协议同意状态。
|
||||
func toggleAgreement() {
|
||||
guard mode.isEditable else { return }
|
||||
isAgreed.toggle()
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 更新长期有效状态;开启后清空截止日期。
|
||||
func updateLongValid(_ value: Bool) {
|
||||
guard mode.isEditable else { return }
|
||||
isLongValid = value
|
||||
if value {
|
||||
endDate = nil
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 更新证件有效期起始日期。
|
||||
func updateStartDate(_ value: Date) {
|
||||
startDate = value
|
||||
guard mode.isEditable else { return }
|
||||
startDate = Calendar.current.startOfDay(for: value)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 更新证件有效期截止日期。
|
||||
func updateEndDate(_ value: Date) {
|
||||
endDate = value
|
||||
guard mode.isEditable, !isLongValid else { return }
|
||||
endDate = Calendar.current.startOfDay(for: value)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 从服务端重新拉取实名认证信息并应用到页面。
|
||||
func load(api: ProfileAPI) async throws {
|
||||
guard !loading else { return }
|
||||
loading = true
|
||||
@ -67,11 +155,37 @@ final class RealNameAuthViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
let response = try await api.realNameInfo()
|
||||
info = response.realNameInfo
|
||||
mode = Self.mode(for: response.realNameInfo)
|
||||
apply(response.realNameInfo)
|
||||
}
|
||||
|
||||
/// 进入短信验证步骤;校验失败时返回对应提示。
|
||||
@discardableResult
|
||||
func goToVerificationStep() -> String? {
|
||||
guard mode.isEditable else {
|
||||
currentStep = .verification
|
||||
notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
if let identityValidationMessage {
|
||||
return identityValidationMessage
|
||||
}
|
||||
guard isAgreed else { return "请阅读并同意用户协议与隐私政策" }
|
||||
currentStep = .verification
|
||||
notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 返回证件资料步骤。
|
||||
func backToIdentityStep() {
|
||||
currentStep = .identity
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 发送实名认证短信验证码并启动 60 秒倒计时。
|
||||
func sendCode(api: ProfileAPI) async throws {
|
||||
guard !sendingCode else { return }
|
||||
guard canRequestCode else { return }
|
||||
sendingCode = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
@ -79,31 +193,56 @@ final class RealNameAuthViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
try await api.realNameSmsVerifyCode()
|
||||
statusMessage = "验证码已发送"
|
||||
statusMessage = "验证码发送成功"
|
||||
startCountdown()
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func prepareIdentityImage(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
|
||||
/// 处理并立即上传身份证图片。
|
||||
func uploadIdentityImage(
|
||||
data: Data,
|
||||
side: RealNameImageSide,
|
||||
uploader: any OSSUploadServing,
|
||||
scenicId: Int,
|
||||
timestamp: TimeInterval = Date().timeIntervalSince1970
|
||||
) async throws {
|
||||
guard mode.isEditable else { return }
|
||||
guard !uploading(side) else { return }
|
||||
|
||||
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 = ""
|
||||
setPreview(processed.data, side: side)
|
||||
setUploading(true, progress: 0, side: side)
|
||||
defer { setUploading(false, progress: progress(for: side), side: side) }
|
||||
|
||||
do {
|
||||
let url = try await uploader.uploadRealNameImage(
|
||||
data: processed.data,
|
||||
fileName: processed.fileName,
|
||||
scenicId: scenicId
|
||||
) { [weak self] progress in
|
||||
self?.setProgress(progress, side: side)
|
||||
}
|
||||
setURL(url, side: side)
|
||||
setProgress(100, side: side)
|
||||
} catch {
|
||||
setPreview(nil, side: side)
|
||||
setURL("", side: side)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除指定一面的证件图片。
|
||||
func deleteIdentityImage(side: RealNameImageSide) {
|
||||
guard mode.isEditable, !uploading(side) else { return }
|
||||
setPreview(nil, side: side)
|
||||
setURL("", side: side)
|
||||
setProgress(0, side: side)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
if info?.verified == true {
|
||||
statusMessage = "已完成实名认证"
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
/// 提交实名认证资料。
|
||||
func submit(api: ProfileAPI) async throws {
|
||||
guard mode.isEditable, !submitting else { return }
|
||||
if let validationMessage {
|
||||
throw RealNameValidationError.message(validationMessage)
|
||||
}
|
||||
@ -114,30 +253,45 @@ final class RealNameAuthViewModel {
|
||||
submitting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
try await uploadPendingIdentityImages(uploader: uploader, scenicId: scenicId)
|
||||
try await api.realNameSubmit(makeRequest())
|
||||
statusMessage = "已提交审核"
|
||||
try await api.realNameSubmit(try makeRequest())
|
||||
statusMessage = "认证成功"
|
||||
notifyStateChange()
|
||||
try await load(api: api)
|
||||
}
|
||||
|
||||
/// 当前完整表单的首个校验错误。
|
||||
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 "证件结束日期不能早于起始日期" }
|
||||
if let identityValidationMessage { return identityValidationMessage }
|
||||
if !isAgreed { return "请阅读并同意用户协议与隐私政策" }
|
||||
if smsCode.trimmed.isEmpty { return "请输入短信验证码" }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 生成与 Android DTO 对齐的实名认证提交请求。
|
||||
func makeRequest() throws -> RealNameAuthRequest {
|
||||
if let validationMessage {
|
||||
throw RealNameValidationError.message(validationMessage)
|
||||
}
|
||||
guard let startDate else {
|
||||
throw RealNameValidationError.message("请选择证件起始日期")
|
||||
}
|
||||
return RealNameAuthRequest(
|
||||
realName: realName.trimmed,
|
||||
idCardNo: Self.normalizedIDCardNumber(idCardNo),
|
||||
smsVerifyCode: smsCode.trimmed,
|
||||
startDate: Self.dateFormatter.string(from: startDate),
|
||||
endDate: isLongValid ? "" : endDate.map(Self.dateFormatter.string(from:)) ?? "",
|
||||
isLongValid: isLongValid ? 1 : 0,
|
||||
frontUrl: frontUrl.trimmed,
|
||||
backUrl: backUrl.trimmed
|
||||
)
|
||||
}
|
||||
|
||||
/// 规范化身份证号码,移除首尾空白并统一大写。
|
||||
nonisolated static func normalizedIDCardNumber(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
}
|
||||
|
||||
/// 校验大陆 18 位身份证号码的格式和校验码。
|
||||
nonisolated static func isValidMainlandIDCardNumber(_ value: String) -> Bool {
|
||||
let number = normalizedIDCardNumber(value)
|
||||
guard number.count == 18 else { return false }
|
||||
@ -152,38 +306,112 @@ final class RealNameAuthViewModel {
|
||||
return chars[17] == checkCodes[sum % 11]
|
||||
}
|
||||
|
||||
private var identityValidationMessage: String? {
|
||||
if realName.trimmed.isEmpty { return "请输入真实姓名" }
|
||||
if idCardNo.trimmed.isEmpty { return "请输入身份证号" }
|
||||
if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" }
|
||||
if backUrl.trimmed.isEmpty { return "请选择身份证国徽面图片" }
|
||||
if frontUrl.trimmed.isEmpty { return "请选择身份证人像面图片" }
|
||||
guard let startDate else { return "请选择证件起始日期" }
|
||||
if !isLongValid {
|
||||
guard let endDate else { return "请选择证件截止日期" }
|
||||
if endDate < startDate { return "证件结束日期不能早于起始日期" }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func apply(_ info: RealNameInfo?) {
|
||||
self.info = info
|
||||
guard let info else {
|
||||
notifyStateChange()
|
||||
isAgreed = false
|
||||
return
|
||||
}
|
||||
realName = info.realName
|
||||
idCardNo = info.idCardNo
|
||||
frontUrl = info.frontUrl ?? ""
|
||||
backUrl = info.backUrl ?? ""
|
||||
pendingFrontImageData = nil
|
||||
pendingBackImageData = nil
|
||||
frontPreviewImageData = nil
|
||||
backPreviewImageData = nil
|
||||
isLongValid = info.isLongValid
|
||||
startDate = Self.date(from: info.startDate) ?? startDate
|
||||
endDate = Self.date(from: info.endDate) ?? endDate
|
||||
startDate = Self.date(from: info.startDate)
|
||||
endDate = info.isLongValid ? nil : Self.date(from: info.endDate)
|
||||
isAgreed = true
|
||||
}
|
||||
|
||||
private static func mode(for info: RealNameInfo?) -> RealNameAuthMode {
|
||||
guard let info else { return .newApplication }
|
||||
switch info.auditStatus {
|
||||
case 2: return .approved
|
||||
case 3: return .rejected
|
||||
default: return .pending
|
||||
}
|
||||
}
|
||||
|
||||
private func uploading(_ side: RealNameImageSide) -> Bool {
|
||||
side == .front ? isFrontUploading : isBackUploading
|
||||
}
|
||||
|
||||
private func progress(for side: RealNameImageSide) -> Int {
|
||||
side == .front ? frontUploadProgress : backUploadProgress
|
||||
}
|
||||
|
||||
private func setPreview(_ data: Data?, side: RealNameImageSide) {
|
||||
if side == .front {
|
||||
frontPreviewImageData = data
|
||||
} else {
|
||||
backPreviewImageData = data
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
private func setURL(_ url: String, side: RealNameImageSide) {
|
||||
if side == .front {
|
||||
frontUrl = url
|
||||
} else {
|
||||
backUrl = url
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
private func setUploading(_ uploading: Bool, progress: Int, side: RealNameImageSide) {
|
||||
if side == .front {
|
||||
isFrontUploading = uploading
|
||||
frontUploadProgress = progress
|
||||
} else {
|
||||
isBackUploading = uploading
|
||||
backUploadProgress = progress
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func setProgress(_ progress: Int, side: RealNameImageSide) {
|
||||
let normalized = max(0, min(100, progress))
|
||||
if side == .front {
|
||||
frontUploadProgress = normalized
|
||||
} else {
|
||||
backUploadProgress = normalized
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func startCountdown(seconds: Int = 60) {
|
||||
countdownTask?.cancel()
|
||||
otpCountdown = seconds
|
||||
countdownTask = Task { [weak self] in
|
||||
guard seconds > 0 else { return }
|
||||
for remaining in stride(from: seconds - 1, through: 0, by: -1) {
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(1))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard let self else { return }
|
||||
self.otpCountdown = remaining
|
||||
self.notifyStateChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
@ -196,33 +424,16 @@ final class RealNameAuthViewModel {
|
||||
return dateFormatter.date(from: value)
|
||||
}
|
||||
|
||||
private func uploadPendingIdentityImages(uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
if let pendingFrontImageData {
|
||||
frontUrl = try await uploader.uploadRealNameImage(
|
||||
data: pendingFrontImageData,
|
||||
fileName: pendingFrontFileName ?? "real_name_front.jpg",
|
||||
scenicId: scenicId
|
||||
) { _ in }
|
||||
self.pendingFrontImageData = nil
|
||||
}
|
||||
if let pendingBackImageData {
|
||||
backUrl = try await uploader.uploadRealNameImage(
|
||||
data: pendingBackImageData,
|
||||
fileName: pendingBackFileName ?? "real_name_back.jpg",
|
||||
scenicId: scenicId
|
||||
) { _ in }
|
||||
self.pendingBackImageData = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证表单校验错误。
|
||||
enum RealNameValidationError: LocalizedError, Equatable {
|
||||
case message(String)
|
||||
|
||||
/// 面向用户展示的校验提示。
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .message(let message): message
|
||||
|
||||
@ -390,6 +390,13 @@ struct PunchPointUploadDialogState: Sendable, Equatable {
|
||||
let progress: Int
|
||||
}
|
||||
|
||||
/// 打卡点状态标签的文案与配色。
|
||||
struct PunchPointStatusPresentation: Sendable, Equatable {
|
||||
let title: String
|
||||
let backgroundColor: UInt
|
||||
let textColor: UInt
|
||||
}
|
||||
|
||||
/// 打卡点 UI 文案与格式化工具。
|
||||
enum PunchPointDisplayFormatter {
|
||||
/// 手机号脱敏。
|
||||
@ -401,32 +408,40 @@ enum PunchPointDisplayFormatter {
|
||||
return "\(prefix)****\(suffix)"
|
||||
}
|
||||
|
||||
/// 审核/运营标签背景色。
|
||||
static func statusColors(_ status: Int) -> (background: UInt, text: UInt) {
|
||||
/// 运营状态标签,严格对应 Android `OperatingTag`。
|
||||
static func operatingStatus(_ status: Int) -> PunchPointStatusPresentation {
|
||||
switch status {
|
||||
case 1:
|
||||
return (0xF0FDF4, 0x09BE4F)
|
||||
return PunchPointStatusPresentation(title: "营运中", backgroundColor: 0xF0FDF4, textColor: 0x22C55E)
|
||||
case 2:
|
||||
return (0xFFF0E2, 0xFF7B00)
|
||||
case 3:
|
||||
return (0xEFF6FF, 0x0073FF)
|
||||
case 4:
|
||||
return (0xFFE7E7, 0xEF4444)
|
||||
return PunchPointStatusPresentation(title: "已暂停", backgroundColor: 0xF4F4F4, textColor: 0x4B5563)
|
||||
default:
|
||||
return (0xF4F4F4, 0x666666)
|
||||
return PunchPointStatusPresentation(title: "未上线", backgroundColor: 0xF4F4F4, textColor: 0x4B5563)
|
||||
}
|
||||
}
|
||||
|
||||
/// 审核状态标签,严格对应 Android `StatusLabelChip`。
|
||||
static func auditStatus(_ status: Int) -> PunchPointStatusPresentation {
|
||||
switch status {
|
||||
case 1, 2:
|
||||
return PunchPointStatusPresentation(title: "已通过", backgroundColor: 0xF0FDF4, textColor: 0x22C55E)
|
||||
case 4:
|
||||
return PunchPointStatusPresentation(title: "已驳回", backgroundColor: 0xFFE7E7, textColor: 0xEF4444)
|
||||
default:
|
||||
return PunchPointStatusPresentation(title: "待审核", backgroundColor: 0xFFF0E2, textColor: 0xFF7B00)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func decodeFlexibleInt64(forKey key: Key) throws -> Int64? {
|
||||
if let value = try decodeIfPresent(Int64.self, forKey: key) {
|
||||
if let value = try? decodeIfPresent(Int64.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try decodeIfPresent(Int.self, forKey: key) {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return Int64(value)
|
||||
}
|
||||
if let value = try decodeIfPresent(String.self, forKey: key) {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return Int64(value)
|
||||
}
|
||||
return nil
|
||||
|
||||
@ -29,11 +29,13 @@ final class PunchPointListViewModel {
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onShowQRCode: ((String) -> Void)?
|
||||
var onBeginDeleteAnimation: ((Int64) -> Void)?
|
||||
|
||||
private var currentPage = 1
|
||||
private var totalCount = 0
|
||||
private let pageSize = 10
|
||||
private let currentScenicIdProvider: () -> Int
|
||||
private var pendingDeleteIDs = Set<Int64>()
|
||||
|
||||
/// 初始化打卡点列表 ViewModel。
|
||||
init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }) {
|
||||
@ -81,12 +83,11 @@ final class PunchPointListViewModel {
|
||||
|
||||
/// 删除打卡点。
|
||||
func delete(item: PunchPointItem, api: any PunchPointAPIProtocol) async {
|
||||
guard !pendingDeleteIDs.contains(item.id) else { return }
|
||||
do {
|
||||
try await api.delete(id: item.id)
|
||||
items.removeAll { $0.id == item.id }
|
||||
totalCount = max(0, totalCount - 1)
|
||||
canLoadMore = items.count < totalCount
|
||||
notifyStateChange()
|
||||
pendingDeleteIDs.insert(item.id)
|
||||
onBeginDeleteAnimation?(item.id)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
@ -94,6 +95,15 @@ final class PunchPointListViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除动画完成后,从列表数据中正式移除条目。
|
||||
func completeDeleteAnimation(id: Int64) {
|
||||
guard pendingDeleteIDs.remove(id) != nil else { return }
|
||||
items.removeAll { $0.id == id }
|
||||
totalCount = max(0, totalCount - 1)
|
||||
canLoadMore = items.count < totalCount
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func load(reset: Bool, showLoading: Bool, api: any PunchPointAPIProtocol) async {
|
||||
if reset {
|
||||
currentPage = 1
|
||||
|
||||
@ -145,8 +145,7 @@ final class WalletAPI: WalletPageServing {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/wallet/withdraw-send-sms",
|
||||
body: EmptyPayload()
|
||||
path: "/api/yf-handset-app/photog/wallet/withdraw-send-sms"
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -169,8 +168,7 @@ final class WalletAPI: WalletPageServing {
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/point/overview",
|
||||
queryItems: [URLQueryItem(name: "staff_id", value: String(staffId))],
|
||||
body: EmptyPayload()
|
||||
queryItems: [URLQueryItem(name: "staff_id", value: String(staffId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -89,6 +89,7 @@ final class WalletViewModel {
|
||||
|
||||
private let staffIdProvider: () -> Int?
|
||||
private let calendar: Calendar
|
||||
private let nowProvider: () -> Date
|
||||
private var withdrawPage = 1
|
||||
private var withdrawCanLoadMore = true
|
||||
private var transactionPage = 1
|
||||
@ -101,10 +102,12 @@ final class WalletViewModel {
|
||||
staffIdProvider: @escaping () -> Int? = {
|
||||
Int(AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
},
|
||||
calendar: Calendar = Calendar(identifier: .gregorian)
|
||||
calendar: Calendar = Calendar(identifier: .gregorian),
|
||||
nowProvider: @escaping () -> Date = Date.init
|
||||
) {
|
||||
self.staffIdProvider = staffIdProvider
|
||||
self.calendar = calendar
|
||||
self.nowProvider = nowProvider
|
||||
}
|
||||
|
||||
/// 首次加载钱包首页全部首屏数据。
|
||||
@ -223,8 +226,11 @@ final class WalletViewModel {
|
||||
private func loadWithdraw(api: any WalletPageServing, refresh: Bool) async {
|
||||
if withdrawLoading && !refresh { return }
|
||||
let targetPage = refresh ? 1 : withdrawPage
|
||||
let previousPage = withdrawPage
|
||||
let previousCanLoadMore = withdrawCanLoadMore
|
||||
withdrawLoading = true
|
||||
withdrawRefreshing = refresh
|
||||
errorMessage = nil
|
||||
if refresh {
|
||||
withdrawCanLoadMore = true
|
||||
withdrawPage = 1
|
||||
@ -238,8 +244,8 @@ final class WalletViewModel {
|
||||
withdrawPage = response.item.isEmpty ? targetPage : targetPage + 1
|
||||
} catch {
|
||||
if refresh {
|
||||
withdrawRecords = []
|
||||
withdrawCanLoadMore = false
|
||||
withdrawPage = previousPage
|
||||
withdrawCanLoadMore = previousCanLoadMore
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
@ -252,9 +258,12 @@ final class WalletViewModel {
|
||||
private func loadTransaction(api: any WalletPageServing, refresh: Bool) async {
|
||||
if transactionLoading && !refresh { return }
|
||||
let targetPage = refresh ? 1 : transactionPage
|
||||
let previousPage = transactionPage
|
||||
let previousCanLoadMore = transactionCanLoadMore
|
||||
let range = dateRange(for: filter)
|
||||
transactionLoading = true
|
||||
transactionRefreshing = refresh
|
||||
errorMessage = nil
|
||||
if refresh {
|
||||
transactionCanLoadMore = true
|
||||
transactionPage = 1
|
||||
@ -276,8 +285,8 @@ final class WalletViewModel {
|
||||
transactionPage = response.list.isEmpty ? targetPage : targetPage + 1
|
||||
} catch {
|
||||
if refresh {
|
||||
transactionGroups = []
|
||||
transactionCanLoadMore = false
|
||||
transactionPage = previousPage
|
||||
transactionCanLoadMore = previousCanLoadMore
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
@ -287,7 +296,8 @@ final class WalletViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func dateRange(for filter: WalletFilter, now: Date = Date()) -> (start: String, end: String) {
|
||||
private func dateRange(for filter: WalletFilter) -> (start: String, end: String) {
|
||||
let now = nowProvider()
|
||||
let end = calendar.startOfDay(for: now)
|
||||
let start = calendar.date(byAdding: .day, value: -filter.dayOffset, to: end) ?? end
|
||||
return (Self.dayFormatter.string(from: start), Self.dayFormatter.string(from: end))
|
||||
@ -367,7 +377,7 @@ final class WithdrawViewModel {
|
||||
|
||||
/// 更新短信验证码。
|
||||
func updateVerificationCode(_ value: String) {
|
||||
verificationCode = value.filter(\.isNumber)
|
||||
verificationCode = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
@ -416,14 +426,17 @@ final class WithdrawViewModel {
|
||||
|
||||
/// 表单是否可提交。
|
||||
var canSubmit: Bool {
|
||||
validationMessage == nil && !submitting
|
||||
withdrawInfo != nil &&
|
||||
!amount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
!verificationCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
!submitting
|
||||
}
|
||||
|
||||
/// 表单校验提示。
|
||||
var validationMessage: String? {
|
||||
guard let withdrawInfo else { return "提现信息加载中" }
|
||||
guard !amount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "请输入提现金额" }
|
||||
guard let amountDecimal = Decimal(string: amount), amountDecimal > 0 else { return "请输入有效的金额" }
|
||||
guard let amountDecimal = Decimal(string: amount) else { return "请输入有效的金额" }
|
||||
let withdrawable = Decimal(string: withdrawInfo.amountWithdrawable) ?? 0
|
||||
if amountDecimal > withdrawable { return "金额不能大于可提现金额" }
|
||||
guard !verificationCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "请输入验证码" }
|
||||
@ -495,6 +508,7 @@ final class PointsRedemptionViewModel {
|
||||
func refreshOverview(api: any WalletPageServing) async {
|
||||
guard let staffId = staffIdProvider(), staffId > 0 else { return }
|
||||
overviewLoading = true
|
||||
errorMessage = nil
|
||||
notifyStateChange()
|
||||
do {
|
||||
let response = try await api.pointOverview(staffId: staffId)
|
||||
@ -527,7 +541,7 @@ final class PointsRedemptionViewModel {
|
||||
return
|
||||
}
|
||||
let points = min(Int(digits) ?? 0, withdrawnPoints)
|
||||
pointsInput = points > 0 ? String(points) : ""
|
||||
pointsInput = String(points)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
@ -566,8 +580,11 @@ final class PointsRedemptionViewModel {
|
||||
private func loadWithdrawList(api: any WalletPageServing, refresh: Bool) async {
|
||||
if withdrawLoading && !refresh { return }
|
||||
let targetPage = refresh ? 1 : withdrawPage
|
||||
let previousPage = withdrawPage
|
||||
let previousCanLoadMore = withdrawCanLoadMore
|
||||
withdrawLoading = true
|
||||
withdrawRefreshing = refresh
|
||||
errorMessage = nil
|
||||
if refresh {
|
||||
withdrawCanLoadMore = true
|
||||
withdrawPage = 1
|
||||
@ -581,8 +598,8 @@ final class PointsRedemptionViewModel {
|
||||
withdrawPage = response.list.isEmpty ? targetPage : targetPage + 1
|
||||
} catch {
|
||||
if refresh {
|
||||
withdrawRecords = []
|
||||
withdrawCanLoadMore = false
|
||||
withdrawPage = previousPage
|
||||
withdrawCanLoadMore = previousCanLoadMore
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user