Add Projects, Schedule, and Invite modules with home routing.

Migrate project management, schedule management, and photographer invitation flows from placeholders, including project image OSS upload and shared business models.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 15:57:15 +08:00
parent 0d4b63d273
commit fb8430889b
40 changed files with 4496 additions and 108 deletions

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

View 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 模块只调用其公开服务协议,不持有钱包业务状态。
## 缓存边界
邀请码、邀请记录、奖励记录和二维码都不落盘。后续如果需要分享图片,应在分享流程中临时生成。

View 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 ""
}
/// StringDouble 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
}
}

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

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