feat: add WeChat sharing and invite flow
This commit is contained in:
46
suixinkan/Features/Invite/API/InviteAPI.swift
Normal file
46
suixinkan/Features/Invite/API/InviteAPI.swift
Normal file
@ -0,0 +1,46 @@
|
||||
//
|
||||
// InviteAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 邀请服务协议,抽象邀请信息与邀请用户列表接口。
|
||||
protocol InviteServing {
|
||||
/// 获取邀请二维码、邀请码和规则信息。
|
||||
func inviteInfo() async throws -> InviteInfoResponse
|
||||
|
||||
/// 获取邀请用户列表。
|
||||
func inviteUserList(page: Int, pageSize: Int) async throws -> [InviteUserItem]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 邀请 API,封装摄影师注册邀请相关接口。
|
||||
final class InviteAPI: InviteServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化邀请 API。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取邀请二维码、邀请码和规则信息。
|
||||
func inviteInfo() async throws -> InviteInfoResponse {
|
||||
try await client.send(APIRequest(method: .get, path: "/api/yf-handset-app/photog/invite-info"))
|
||||
}
|
||||
|
||||
/// 获取邀请用户列表。
|
||||
func inviteUserList(page: Int = 1, pageSize: Int = 20) async throws -> [InviteUserItem] {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/invite/user-list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
172
suixinkan/Features/Invite/Models/InviteModels.swift
Normal file
172
suixinkan/Features/Invite/Models/InviteModels.swift
Normal file
@ -0,0 +1,172 @@
|
||||
//
|
||||
// InviteModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 邀请信息响应实体,表示邀请码、邀请链接和规则文案。
|
||||
struct InviteInfoResponse: Decodable, Equatable {
|
||||
let enableInvite: Bool
|
||||
let inviteCode: String
|
||||
let inviteUrl: String
|
||||
let description: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case enableInvite = "enable_invite"
|
||||
case inviteCode = "invite_code"
|
||||
case inviteUrl = "invite_url"
|
||||
case description
|
||||
}
|
||||
|
||||
/// 创建邀请信息响应实体。
|
||||
init(enableInvite: Bool, inviteCode: String, inviteUrl: String, description: [String]) {
|
||||
self.enableInvite = enableInvite
|
||||
self.inviteCode = inviteCode
|
||||
self.inviteUrl = inviteUrl
|
||||
self.description = description
|
||||
}
|
||||
|
||||
/// 宽松解码邀请信息,兼容后端字符串/数字/布尔混用。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
enableInvite = try container.decodeInviteLossyBool(forKey: .enableInvite) ?? true
|
||||
inviteCode = try container.decodeInviteLossyString(forKey: .inviteCode)
|
||||
inviteUrl = try container.decodeInviteLossyString(forKey: .inviteUrl)
|
||||
description = (try? container.decodeIfPresent([String].self, forKey: .description)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请用户实体,表示邀请记录里的摄影师。
|
||||
struct InviteUserItem: Decodable, Hashable {
|
||||
let id: Int
|
||||
let realName: String
|
||||
let phone: String
|
||||
let avatar: String
|
||||
let createdAt: String
|
||||
let inviteLevel: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case realName = "real_name"
|
||||
case phone
|
||||
case avatar
|
||||
case createdAt = "created_at"
|
||||
case inviteLevel = "invite_level"
|
||||
}
|
||||
|
||||
/// 创建邀请用户实体。
|
||||
init(id: Int, realName: String, phone: String, avatar: String, createdAt: String, inviteLevel: Int) {
|
||||
self.id = id
|
||||
self.realName = realName
|
||||
self.phone = phone
|
||||
self.avatar = avatar
|
||||
self.createdAt = createdAt
|
||||
self.inviteLevel = inviteLevel
|
||||
}
|
||||
|
||||
/// 宽松解码邀请用户字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeInviteLossyInt(forKey: .id) ?? 0
|
||||
realName = try container.decodeInviteLossyString(forKey: .realName)
|
||||
phone = try container.decodeInviteLossyString(forKey: .phone)
|
||||
avatar = try container.decodeInviteLossyString(forKey: .avatar)
|
||||
createdAt = try container.decodeInviteLossyString(forKey: .createdAt)
|
||||
inviteLevel = try container.decodeInviteLossyInt(forKey: .inviteLevel) ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请记录分段类型。
|
||||
enum InviteRecordTab: Int, CaseIterable, Equatable {
|
||||
case invite = 0
|
||||
case reward = 1
|
||||
|
||||
/// 分段标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .invite:
|
||||
return "邀请记录"
|
||||
case .reward:
|
||||
return "奖励记录"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请记录行展示实体。
|
||||
struct InviteDisplayRow: Hashable {
|
||||
let id: String
|
||||
let title: String
|
||||
let phone: String
|
||||
let avatar: String
|
||||
let time: String
|
||||
let inviteLevel: Int
|
||||
|
||||
/// 邀请层级文案。
|
||||
var levelText: String {
|
||||
inviteLevel == 1 ? "一级" : "二级"
|
||||
}
|
||||
|
||||
/// 邀请层级是否为一级。
|
||||
var isPrimaryLevel: Bool {
|
||||
inviteLevel == 1
|
||||
}
|
||||
|
||||
/// 头像缺省显示文字。
|
||||
var avatarPlaceholder: String {
|
||||
if let first = title.first {
|
||||
return String(first)
|
||||
}
|
||||
if let first = phone.first {
|
||||
return String(first)
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func decodeInviteLossyString(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 ? "1" : "0"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func decodeInviteLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Int(text) ?? Double(text).map(Int.init)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeInviteLossyBool(forKey key: Key) throws -> Bool? {
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value != 0
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if ["1", "true", "yes"].contains(text) { return true }
|
||||
if ["0", "false", "no"].contains(text) { return false }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
218
suixinkan/Features/Invite/ViewModels/InviteViewModels.swift
Normal file
218
suixinkan/Features/Invite/ViewModels/InviteViewModels.swift
Normal file
@ -0,0 +1,218 @@
|
||||
//
|
||||
// InviteViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 邀请页 ViewModel,负责加载邀请信息、生成二维码和暴露复制内容。
|
||||
final class PhotographerInviteViewModel {
|
||||
private(set) var isLoading = false
|
||||
private(set) var inviteCode = ""
|
||||
private(set) var inviteUrl = ""
|
||||
private(set) var rules: [String] = []
|
||||
private(set) var qrImage: UIImage?
|
||||
private(set) var errorMessage: String?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
/// 重新加载邀请信息。
|
||||
func reload(api: any InviteServing) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
notifyStateChange()
|
||||
|
||||
do {
|
||||
let response = try await api.inviteInfo()
|
||||
inviteCode = response.inviteCode
|
||||
inviteUrl = response.inviteUrl
|
||||
rules = response.description
|
||||
qrImage = QRCodeGenerator.generateStyledQRCode(
|
||||
content: response.inviteUrl,
|
||||
pixelSize: 560,
|
||||
backgroundColor: .white,
|
||||
cornerRadius: 0
|
||||
)
|
||||
} catch {
|
||||
clear()
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 返回待复制的邀请码。
|
||||
func inviteCodeForCopy() -> String {
|
||||
inviteCode
|
||||
}
|
||||
|
||||
/// 返回待复制的邀请链接。
|
||||
func inviteUrlForCopy() -> String {
|
||||
inviteUrl
|
||||
}
|
||||
|
||||
private func clear() {
|
||||
inviteCode = ""
|
||||
inviteUrl = ""
|
||||
rules = []
|
||||
qrImage = nil
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请记录 ViewModel,负责邀请用户、奖励流水和钱包汇总分页。
|
||||
final class InviteRecordViewModel {
|
||||
private(set) var selectedTab: InviteRecordTab = .invite
|
||||
private(set) var totalRewardText = "0.00"
|
||||
private(set) var withdrawableText = "0.00"
|
||||
private(set) var inviteRows: [InviteDisplayRow] = []
|
||||
private(set) var rewardRows: [WalletTransactionItem] = []
|
||||
private(set) var isInviteRefreshing = false
|
||||
private(set) var isRewardRefreshing = false
|
||||
private(set) var canLoadMoreInvite = false
|
||||
private(set) var canLoadMoreReward = false
|
||||
private(set) var errorMessage: String?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
private var invitePage = 1
|
||||
private let invitePageSize = 20
|
||||
private var rewardPage = 1
|
||||
private let rewardPageSize = 10
|
||||
private var isLoadingInvite = false
|
||||
private var isLoadingReward = false
|
||||
|
||||
/// 初次加载邀请记录和钱包汇总。
|
||||
func loadInitial(inviteAPI: any InviteServing, walletAPI: any WalletServing) async {
|
||||
await refreshInvite(inviteAPI: inviteAPI, walletAPI: walletAPI)
|
||||
}
|
||||
|
||||
/// 切换分段并在需要时加载奖励记录。
|
||||
func selectTab(_ tab: InviteRecordTab, inviteAPI: any InviteServing, walletAPI: any WalletServing) async {
|
||||
guard tab != selectedTab else { return }
|
||||
selectedTab = tab
|
||||
notifyStateChange()
|
||||
if tab == .reward, rewardRows.isEmpty {
|
||||
await refreshReward(walletAPI: walletAPI)
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新邀请记录和钱包汇总。
|
||||
func refreshInvite(inviteAPI: any InviteServing, walletAPI: any WalletServing) async {
|
||||
await loadSummary(walletAPI: walletAPI)
|
||||
await loadInviteRows(inviteAPI: inviteAPI, refresh: true)
|
||||
}
|
||||
|
||||
/// 加载下一页邀请记录。
|
||||
func loadMoreInvite(inviteAPI: any InviteServing) async {
|
||||
guard canLoadMoreInvite, !isInviteRefreshing else { return }
|
||||
await loadInviteRows(inviteAPI: inviteAPI, refresh: false)
|
||||
}
|
||||
|
||||
/// 刷新奖励记录和钱包汇总。
|
||||
func refreshReward(walletAPI: any WalletServing) async {
|
||||
await loadSummary(walletAPI: walletAPI)
|
||||
await loadRewardRows(walletAPI: walletAPI, refresh: true)
|
||||
}
|
||||
|
||||
/// 加载下一页奖励记录。
|
||||
func loadMoreReward(walletAPI: any WalletServing) async {
|
||||
guard canLoadMoreReward, !isRewardRefreshing else { return }
|
||||
await loadRewardRows(walletAPI: walletAPI, refresh: false)
|
||||
}
|
||||
|
||||
private func loadSummary(walletAPI: any WalletServing) async {
|
||||
do {
|
||||
let summary = try await walletAPI.walletSummary(type: 2)
|
||||
totalRewardText = summary.amountTotal.isEmpty ? "0.00" : summary.amountTotal
|
||||
withdrawableText = summary.amountWithdrawable.isEmpty ? "0.00" : summary.amountWithdrawable
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func loadInviteRows(inviteAPI: any InviteServing, refresh: Bool) async {
|
||||
if isLoadingInvite && !refresh { return }
|
||||
if refresh { invitePage = 1 }
|
||||
isLoadingInvite = true
|
||||
isInviteRefreshing = refresh
|
||||
errorMessage = nil
|
||||
notifyStateChange()
|
||||
|
||||
do {
|
||||
let users = try await inviteAPI.inviteUserList(page: invitePage, pageSize: invitePageSize)
|
||||
let mapped = users.map { user in
|
||||
InviteDisplayRow(
|
||||
id: "invite_\(user.id)",
|
||||
title: user.realName.isEmpty ? "**\(user.phone.suffix(4))" : user.realName,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar,
|
||||
time: user.createdAt,
|
||||
inviteLevel: user.inviteLevel
|
||||
)
|
||||
}
|
||||
inviteRows = refresh ? mapped : inviteRows + mapped
|
||||
invitePage += 1
|
||||
canLoadMoreInvite = users.count >= invitePageSize
|
||||
} catch {
|
||||
if refresh {
|
||||
inviteRows = []
|
||||
canLoadMoreInvite = false
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoadingInvite = false
|
||||
isInviteRefreshing = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func loadRewardRows(walletAPI: any WalletServing, refresh: Bool) async {
|
||||
if isLoadingReward && !refresh { return }
|
||||
if refresh { rewardPage = 1 }
|
||||
isLoadingReward = true
|
||||
isRewardRefreshing = refresh
|
||||
errorMessage = nil
|
||||
notifyStateChange()
|
||||
|
||||
do {
|
||||
let response = try await walletAPI.walletTransactionList(
|
||||
startDate: "2025-01-01",
|
||||
endDate: Self.dayFormatter.string(from: Date()),
|
||||
page: rewardPage,
|
||||
pageSize: rewardPageSize,
|
||||
type: 2
|
||||
)
|
||||
rewardRows = refresh ? response.list : rewardRows + response.list
|
||||
rewardPage += 1
|
||||
canLoadMoreReward = response.list.count >= rewardPageSize && rewardRows.count < response.total
|
||||
} catch {
|
||||
if refresh {
|
||||
rewardRows = []
|
||||
canLoadMoreReward = false
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoadingReward = false
|
||||
isRewardRefreshing = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
|
||||
private static let dayFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user