新增项目、排期与邀请模块,并接入首页路由
将项目管理、排期管理与摄影师邀请流程从占位页迁移为完整实现,包含项目图片 OSS 上传与共享业务模型。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
50
suixinkan/Features/Invite/API/InviteAPI.swift
Normal file
50
suixinkan/Features/Invite/API/InviteAPI.swift
Normal file
@ -0,0 +1,50 @@
|
||||
//
|
||||
// InviteAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 邀请服务协议,抽象邀请信息和邀请记录接口。
|
||||
@MainActor
|
||||
protocol InviteServing {
|
||||
/// 获取邀请二维码、邀请码和规则信息。
|
||||
func inviteInfo() async throws -> InviteInfoResponse
|
||||
|
||||
/// 获取邀请用户列表。
|
||||
func inviteUserList(page: Int, pageSize: Int) async throws -> [InviteUserItem]
|
||||
}
|
||||
|
||||
/// 邀请 API,封装摄影师邀请相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class InviteAPI: InviteServing {
|
||||
@ObservationIgnored 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: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
30
suixinkan/Features/Invite/Invite.md
Normal file
30
suixinkan/Features/Invite/Invite.md
Normal file
@ -0,0 +1,30 @@
|
||||
# Invite 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Invite 模块负责首页 `registration_invitation`、`photographer_invite` 和 `invite_record` 入口。
|
||||
|
||||
邀请页展示邀请码、邀请链接、二维码和规则;邀请记录页展示邀请用户和奖励记录。模块不保存业务状态到全局对象。
|
||||
|
||||
## 邀请页
|
||||
|
||||
`PhotographerInviteViewModel` 通过 `InviteAPI.inviteInfo()` 获取邀请信息,并使用 CoreImage 在本地生成二维码图片。二维码图片只用于当前页面展示,不落盘。
|
||||
|
||||
复制邀请链接时只由 View 层写入剪贴板,ViewModel 不关心系统剪贴板状态。
|
||||
|
||||
## 邀请记录
|
||||
|
||||
`InviteRecordViewModel` 提供“邀请记录 / 奖励记录”分段:
|
||||
- 邀请记录来自 `InviteAPI.inviteUserList`。
|
||||
- 奖励记录复用 `WalletAPI.walletEarningDetail`。
|
||||
- 顶部奖励汇总复用 `WalletAPI.walletSummary(type: 2)`。
|
||||
|
||||
分页状态、筛选分段和展示行只存在 ViewModel 内存中。
|
||||
|
||||
## 接口边界
|
||||
|
||||
`InviteAPI` 只封装邀请信息和邀请用户列表。钱包收益和提现入口继续由 Wallet 模块负责,Invite 模块只调用其公开服务协议,不持有钱包业务状态。
|
||||
|
||||
## 缓存边界
|
||||
|
||||
邀请码、邀请记录、奖励记录和二维码都不落盘。后续如果需要分享图片,应在分享流程中临时生成。
|
||||
156
suixinkan/Features/Invite/Models/InviteModels.swift
Normal file
156
suixinkan/Features/Invite/Models/InviteModels.swift
Normal file
@ -0,0 +1,156 @@
|
||||
//
|
||||
// InviteModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
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(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, Identifiable, 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(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) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请记录页面展示行实体,统一邀请用户和奖励明细的展示字段。
|
||||
struct InviteDisplayRow: Identifiable, Equatable {
|
||||
let id: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let amount: String
|
||||
let time: String
|
||||
let phone: String
|
||||
let avatar: String
|
||||
let inviteLevel: Int
|
||||
|
||||
/// 邀请层级文案。
|
||||
var levelText: String {
|
||||
inviteLevel == 1 ? "一级" : "二级"
|
||||
}
|
||||
|
||||
/// 头像占位文案。
|
||||
var avatarPlaceholder: String {
|
||||
if let first = title.first {
|
||||
return String(first)
|
||||
}
|
||||
if let first = phone.first {
|
||||
return String(first)
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请记录分段类型。
|
||||
enum InviteRecordTab: CaseIterable, Equatable {
|
||||
case invite
|
||||
case reward
|
||||
|
||||
/// 分段标题。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .invite: return "邀请记录"
|
||||
case .reward: return "奖励记录"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||
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 ""
|
||||
}
|
||||
|
||||
/// 将 String、Double 和 Int 宽松解码为 Int。
|
||||
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
|
||||
}
|
||||
|
||||
/// 将 String、数字和 Bool 宽松解码为 Bool。
|
||||
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
|
||||
}
|
||||
}
|
||||
204
suixinkan/Features/Invite/ViewModels/InviteViewModels.swift
Normal file
204
suixinkan/Features/Invite/ViewModels/InviteViewModels.swift
Normal file
@ -0,0 +1,204 @@
|
||||
//
|
||||
// InviteViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import Foundation
|
||||
import Observation
|
||||
import UIKit
|
||||
|
||||
/// 邀请页 ViewModel,负责加载邀请信息、生成二维码和复制内容。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PhotographerInviteViewModel {
|
||||
private(set) var loading = false
|
||||
private(set) var inviteCode = ""
|
||||
private(set) var inviteUrl = ""
|
||||
private(set) var rules: [String] = []
|
||||
private(set) var qrImage: UIImage?
|
||||
var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private let qrContext = CIContext()
|
||||
@ObservationIgnored private let qrFilter = CIFilter.qrCodeGenerator()
|
||||
|
||||
/// 重新加载邀请信息。
|
||||
func reload(api: any InviteServing) async {
|
||||
loading = true
|
||||
errorMessage = nil
|
||||
defer { loading = false }
|
||||
do {
|
||||
let response = try await api.inviteInfo()
|
||||
inviteCode = response.inviteCode
|
||||
inviteUrl = response.inviteUrl
|
||||
rules = response.description
|
||||
qrImage = makeQrImage(from: response.inviteUrl)
|
||||
} catch {
|
||||
clear()
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制邀请码。
|
||||
func copyInviteCode() {
|
||||
UIPasteboard.general.string = inviteCode
|
||||
}
|
||||
|
||||
/// 复制邀请链接。
|
||||
func copyInviteUrl() {
|
||||
UIPasteboard.general.string = inviteUrl
|
||||
}
|
||||
|
||||
/// 根据文本生成二维码图片。
|
||||
func makeQrImage(from text: String) -> UIImage? {
|
||||
guard !text.isEmpty else { return nil }
|
||||
qrFilter.setValue(Data(text.utf8), forKey: "inputMessage")
|
||||
qrFilter.setValue("M", forKey: "inputCorrectionLevel")
|
||||
guard let output = qrFilter.outputImage else { return nil }
|
||||
let scaled = output.transformed(by: CGAffineTransform(scaleX: 12, y: 12))
|
||||
guard let cgImage = qrContext.createCGImage(scaled, from: scaled.extent) else { return nil }
|
||||
return UIImage(cgImage: cgImage)
|
||||
}
|
||||
|
||||
private func clear() {
|
||||
inviteCode = ""
|
||||
inviteUrl = ""
|
||||
rules = []
|
||||
qrImage = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请记录 ViewModel,负责邀请用户、奖励明细和钱包汇总分页。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class InviteRecordViewModel {
|
||||
var tab: InviteRecordTab = .invite
|
||||
private(set) var totalRewardText = "¥ 0.00"
|
||||
private(set) var withdrawableText = "¥ 0.00"
|
||||
private(set) var displayRows: [InviteDisplayRow] = []
|
||||
private(set) var loading = false
|
||||
private(set) var loadingMore = false
|
||||
private(set) var hasMore = false
|
||||
var errorMessage: String?
|
||||
|
||||
private var inviteRows: [InviteDisplayRow] = []
|
||||
private var rewardRows: [InviteDisplayRow] = []
|
||||
private var invitePage = 1
|
||||
private var rewardPage = 1
|
||||
private let invitePageSize = 20
|
||||
private let rewardPageSize = 10
|
||||
|
||||
/// 重新加载或追加邀请记录数据。
|
||||
func reload(inviteAPI: any InviteServing, walletAPI: any WalletServing, refresh: Bool) async {
|
||||
if refresh {
|
||||
loading = true
|
||||
} else {
|
||||
guard hasMore else { return }
|
||||
loadingMore = true
|
||||
}
|
||||
errorMessage = nil
|
||||
defer {
|
||||
loading = false
|
||||
loadingMore = false
|
||||
}
|
||||
|
||||
do {
|
||||
async let summary = walletAPI.walletSummary(type: 2)
|
||||
if tab == .invite {
|
||||
try await loadInviteRows(api: inviteAPI, refresh: refresh)
|
||||
} else {
|
||||
try await loadRewardRows(api: walletAPI, refresh: refresh)
|
||||
}
|
||||
let summaryValue = try await summary
|
||||
totalRewardText = "¥ \(summaryValue.amountTotal)"
|
||||
withdrawableText = "¥ \(summaryValue.amountWithdrawable)"
|
||||
displayRows = tab == .invite ? inviteRows : rewardRows
|
||||
} catch {
|
||||
if refresh {
|
||||
clear()
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换邀请记录分段并刷新。
|
||||
func selectTab(_ newTab: InviteRecordTab, inviteAPI: any InviteServing, walletAPI: any WalletServing) async {
|
||||
tab = newTab
|
||||
await reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true)
|
||||
}
|
||||
|
||||
private func loadInviteRows(api: any InviteServing, refresh: Bool) async throws {
|
||||
let users = try await api.inviteUserList(page: refresh ? 1 : invitePage, pageSize: invitePageSize)
|
||||
let mapped = users.map {
|
||||
InviteDisplayRow(
|
||||
id: "invite_\($0.id)",
|
||||
title: $0.realName.isEmpty ? "**\($0.phone.suffix(4))" : $0.realName,
|
||||
subtitle: "",
|
||||
amount: "",
|
||||
time: $0.createdAt,
|
||||
phone: $0.phone,
|
||||
avatar: $0.avatar,
|
||||
inviteLevel: $0.inviteLevel
|
||||
)
|
||||
}
|
||||
if refresh {
|
||||
inviteRows = mapped
|
||||
invitePage = 2
|
||||
} else {
|
||||
inviteRows.append(contentsOf: mapped)
|
||||
invitePage += 1
|
||||
}
|
||||
hasMore = users.count >= invitePageSize
|
||||
}
|
||||
|
||||
private func loadRewardRows(api: any WalletServing, refresh: Bool) async throws {
|
||||
let response = try await api.walletEarningDetail(
|
||||
startDate: "2025-01-01",
|
||||
endDate: Self.dayFormatter.string(from: Date()),
|
||||
page: refresh ? 1 : rewardPage,
|
||||
pageSize: rewardPageSize
|
||||
)
|
||||
let mapped = response.list.flatMap { group in
|
||||
group.items.map {
|
||||
InviteDisplayRow(
|
||||
id: "reward_\($0.id)",
|
||||
title: $0.typeLabel.isEmpty ? "奖励记录" : $0.typeLabel,
|
||||
subtitle: $0.withdrawLabel ?? "订单尾号:\($0.orderNumberSuffix.isEmpty ? "--" : $0.orderNumberSuffix)",
|
||||
amount: $0.amount.isEmpty ? "--" : $0.amount,
|
||||
time: $0.createdAt.isEmpty ? group.date : $0.createdAt,
|
||||
phone: "",
|
||||
avatar: "",
|
||||
inviteLevel: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
if refresh {
|
||||
rewardRows = mapped
|
||||
rewardPage = 2
|
||||
} else {
|
||||
rewardRows.append(contentsOf: mapped)
|
||||
rewardPage += 1
|
||||
}
|
||||
hasMore = rewardRows.count < response.total && !mapped.isEmpty
|
||||
}
|
||||
|
||||
private func clear() {
|
||||
totalRewardText = "¥ 0.00"
|
||||
withdrawableText = "¥ 0.00"
|
||||
displayRows = []
|
||||
inviteRows = []
|
||||
rewardRows = []
|
||||
invitePage = 1
|
||||
rewardPage = 1
|
||||
hasMore = false
|
||||
}
|
||||
|
||||
private static let dayFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
284
suixinkan/Features/Invite/Views/InviteViews.swift
Normal file
284
suixinkan/Features/Invite/Views/InviteViews.swift
Normal file
@ -0,0 +1,284 @@
|
||||
//
|
||||
// InviteViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 摄影师邀请页面,展示邀请二维码、邀请码、规则和邀请记录入口。
|
||||
struct PhotographerInviteView: View {
|
||||
@Environment(InviteAPI.self) private var inviteAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = PhotographerInviteViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
inviteCard
|
||||
ruleCard
|
||||
benefitCard
|
||||
NavigationLink(value: HomeRoute.inviteRecord) {
|
||||
Label("邀请记录", systemImage: "calendar.badge.clock")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("摄影师邀请")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await reload(showLoading: true) }
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
}
|
||||
|
||||
private var inviteCard: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if let qrImage = viewModel.qrImage {
|
||||
Image(uiImage: qrImage)
|
||||
.resizable()
|
||||
.interpolation(.none)
|
||||
.scaledToFit()
|
||||
.frame(width: 240, height: 240)
|
||||
} else {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.system(size: 72))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.frame(width: 240, height: 240)
|
||||
.background(Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
HStack {
|
||||
Text("邀请码")
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(viewModel.inviteCode.isEmpty ? "--" : viewModel.inviteCode)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
Spacer()
|
||||
Button("复制") {
|
||||
viewModel.copyInviteCode()
|
||||
toastCenter.show("邀请码已复制")
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
}
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Button {
|
||||
viewModel.copyInviteUrl()
|
||||
toastCenter.show("邀请链接已复制")
|
||||
} label: {
|
||||
Label("复制链接", systemImage: "link")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
}
|
||||
Button {
|
||||
viewModel.copyInviteUrl()
|
||||
toastCenter.show("链接已复制,可粘贴分享")
|
||||
} label: {
|
||||
Label("分享", systemImage: "square.and.arrow.up")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
}
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
private var ruleCard: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text("收益规则")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
let rules = viewModel.rules.isEmpty ? ["直接邀请将获得订单奖励", "间接邀请将获得额外奖励", "具体规则以后端配置为准"] : viewModel.rules
|
||||
ForEach(Array(rules.enumerated()), id: \.offset) { index, rule in
|
||||
HStack(alignment: .top, spacing: AppMetrics.Spacing.small) {
|
||||
Text("\(index + 1)")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 22, height: 22)
|
||||
.background(AppDesign.primary, in: Circle())
|
||||
Text(rule)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
private var benefitCard: some View {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(["免费剪辑修图", "免费云盘相册", "免费线上流量", "景区运营身份"], id: \.self) { title in
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 54)
|
||||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
private func reload(showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading) {
|
||||
await viewModel.reload(api: inviteAPI)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请记录页面,展示邀请用户和奖励记录。
|
||||
struct InviteRecordView: View {
|
||||
@Environment(InviteAPI.self) private var inviteAPI
|
||||
@Environment(WalletAPI.self) private var walletAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = InviteRecordViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Picker("记录类型", selection: $viewModel.tab) {
|
||||
ForEach(InviteRecordTab.allCases, id: \.self) { tab in
|
||||
Text(tab.title).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.onChange(of: viewModel.tab) { _, newValue in
|
||||
Task { await viewModel.selectTab(newValue, inviteAPI: inviteAPI, walletAPI: walletAPI) }
|
||||
}
|
||||
recordList
|
||||
NavigationLink(value: HomeRoute.wallet) {
|
||||
Text("去钱包提现")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("邀请记录")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await reload(refresh: true, showLoading: true) }
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 0) {
|
||||
metric("累计奖励金额", viewModel.totalRewardText)
|
||||
Rectangle()
|
||||
.fill(.white.opacity(0.6))
|
||||
.frame(width: 1, height: 54)
|
||||
metric("可提现金额", viewModel.withdrawableText)
|
||||
}
|
||||
.padding(.vertical, AppMetrics.Spacing.large)
|
||||
.background(AppDesign.primary)
|
||||
}
|
||||
|
||||
private func metric(_ title: String, _ value: String) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(.white.opacity(0.78))
|
||||
Text(value)
|
||||
.font(.system(size: AppMetrics.FontSize.title2, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var recordList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
if viewModel.displayRows.isEmpty && !viewModel.loading {
|
||||
ContentUnavailableView("暂无记录", systemImage: "tray")
|
||||
.frame(minHeight: 280)
|
||||
}
|
||||
ForEach(viewModel.displayRows) { row in
|
||||
InviteRecordRow(row: row, tab: viewModel.tab)
|
||||
}
|
||||
if viewModel.hasMore {
|
||||
ProgressView()
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.onAppear {
|
||||
Task { await reload(refresh: false, showLoading: false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
}
|
||||
.refreshable { await reload(refresh: true, showLoading: false) }
|
||||
}
|
||||
|
||||
private func reload(refresh: Bool, showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading && refresh) {
|
||||
await viewModel.reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: refresh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请记录展示行。
|
||||
private struct InviteRecordRow: View {
|
||||
let row: InviteDisplayRow
|
||||
let tab: InviteRecordTab
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if tab == .invite {
|
||||
RemoteAvatarImage(urlString: row.avatar, iconSize: 26)
|
||||
.frame(width: 48, height: 48)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "yensign.circle.fill")
|
||||
.font(.system(size: 34))
|
||||
.foregroundStyle(AppDesign.success)
|
||||
.frame(width: 48, height: 48)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(row.title)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
if tab == .invite {
|
||||
Text(row.levelText)
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
Text(tab == .invite ? row.phone : row.subtitle)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(row.time)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
if tab == .reward {
|
||||
Text(row.amount)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.success)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(Color(hex: 0xE5E7EB))
|
||||
.frame(height: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user