新增项目、排期与邀请模块,并接入首页路由

将项目管理、排期管理与摄影师邀请流程从占位页迁移为完整实现,包含项目图片 OSS 上传与共享业务模型。

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

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