Files
suixinkan_ios_new/suixinkan/Features/Statistics/Views/StatisticsView.swift
汉秋 7fd964fe19 Add Lottie-based global loading overlay across key flows.
Introduce GlobalLoadingCenter with reference counting, wire it through RootView and major auth/order/profile screens, and add the loading animation asset plus unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 11:25:56 +08:00

327 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// StatisticsView.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import SwiftUI
/// Tab
struct StatisticsView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(PermissionContext.self) private var permissionContext
@Environment(StatisticsAPI.self) private var statisticsAPI
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.globalLoading) private var globalLoading
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var viewModel = StatisticsViewModel()
private var currentScenicId: Int? {
accountContext.currentScenic?.id
}
private var currentRoleId: Int? {
permissionContext.currentRole?.id
}
private var contentMaxWidth: CGFloat {
horizontalSizeClass == .regular ? 900 : .infinity
}
var body: some View {
Group {
if currentScenicId == nil {
ContentUnavailableView(
"缺少经营上下文",
systemImage: "chart.bar.doc.horizontal",
description: Text("请先在首页选择景区后查看数据看板。")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
} else {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
overviewCard
detailCard
}
.frame(maxWidth: contentMaxWidth)
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.top, AppMetrics.Spacing.medium)
.padding(.bottom, AppMetrics.Spacing.pageVertical)
.frame(maxWidth: .infinity)
}
.refreshable { await reload(showLoading: false) }
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
}
}
.navigationTitle("数据")
.navigationBarTitleDisplayMode(.inline)
.task { await reload() }
.onChange(of: accountContext.currentScenic?.id) { _, _ in
Task { await reload() }
}
.onChange(of: permissionContext.currentRole?.id) { _, _ in
Task { await reload() }
}
}
private var overviewCard: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack(spacing: 0) {
Text("已选日期:")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
Text(viewModel.selectedPeriod.selectedTimeText)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.primary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
HStack(spacing: AppMetrics.Spacing.small) {
ForEach(StatisticsPeriod.allCases) { period in
periodButton(period)
}
}
VStack(spacing: AppMetrics.Spacing.small) {
statCard(
title: "订单总金额",
value: amountText(viewModel.summary.orderAmountValue),
textColor: Color(hex: 0x22C55E),
backgroundColor: Color(hex: 0xF0FDF4),
icon: "doc.text.fill"
)
HStack(spacing: AppMetrics.Spacing.small) {
statCard(
title: "订单总数",
value: "\(viewModel.summary.orderCount)",
textColor: Color(hex: 0x7F00FF),
backgroundColor: Color(hex: 0xEBD8FF),
icon: "chart.bar.fill"
)
statCard(
title: "实收金额",
value: amountText(viewModel.summary.receivedAmountValue),
textColor: Color(hex: 0x22C55E),
backgroundColor: Color(hex: 0xFFF0E2),
icon: "yensign.circle.fill"
)
}
HStack(spacing: AppMetrics.Spacing.small) {
statCard(
title: "客单价",
value: amountText(viewModel.summary.orderPriceAverageValue),
textColor: AppDesign.primary,
backgroundColor: Color(hex: 0xEFF6FF),
icon: "tag.fill"
)
statCard(
title: "退款金额",
value: amountText(viewModel.summary.refundAmountValue),
textColor: Color(hex: 0xEF4444),
backgroundColor: Color(hex: 0xFFE7E7),
icon: "arrow.down.doc.fill"
)
}
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
private var detailCard: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Text("时间范围")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .bold))
.foregroundStyle(.black)
Spacer()
Menu {
ForEach(StatisticsPeriod.allCases) { period in
Button(period.rawValue) {
Task { await selectPeriod(period) }
}
}
} label: {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Text(viewModel.selectedPeriod.rawValue)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(Color(hex: 0x4B5563))
Spacer(minLength: AppMetrics.Spacing.small)
Image(systemName: "chevron.down")
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(Color(hex: 0x4B5563))
}
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(width: 166, height: 36)
.background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
Divider()
if viewModel.loading && viewModel.dailyItems.isEmpty {
Color.clear
.frame(maxWidth: .infinity)
.frame(minHeight: 280)
} else if viewModel.dailyItems.isEmpty {
ContentUnavailableView("暂无数据", systemImage: "tray", description: Text("可切换时间范围或下拉刷新。"))
.frame(minHeight: 280)
} else {
VStack(spacing: 0) {
ForEach(viewModel.dailyItems) { item in
dailyDataRow(item)
.onAppear {
guard item.id == viewModel.dailyItems.last?.id else { return }
Task { await loadMore() }
}
if item.id != viewModel.dailyItems.last?.id {
Divider()
}
}
if viewModel.loadingMore {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, AppMetrics.Spacing.small)
}
}
}
}
.padding(AppMetrics.Spacing.medium)
.frame(minHeight: 420, alignment: .top)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
private func periodButton(_ period: StatisticsPeriod) -> some View {
let isSelected = period == viewModel.selectedPeriod
return Button {
Task { await selectPeriod(period) }
} label: {
Text(period.rawValue)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: isSelected ? .semibold : .medium))
.foregroundStyle(isSelected ? .white : Color(hex: 0x4B5563))
.frame(maxWidth: .infinity)
.frame(height: 34)
.background(isSelected ? AppDesign.primary : Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: 17))
}
.buttonStyle(.plain)
}
private func statCard(title: String, value: String, textColor: Color, backgroundColor: Color, icon: String) -> some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(.black.opacity(0.6))
.lineLimit(1)
Text(value)
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
.foregroundStyle(textColor)
.lineLimit(1)
.minimumScaleFactor(0.72)
}
Spacer(minLength: AppMetrics.Spacing.xxSmall)
statisticIcon(symbol: icon, color: textColor)
}
.padding(.horizontal, AppMetrics.Spacing.small)
.padding(.vertical, AppMetrics.Spacing.small)
.frame(maxWidth: .infinity)
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 8))
}
private func statisticIcon(symbol: String, color: Color) -> some View {
ZStack {
RoundedRectangle(cornerRadius: 7)
.fill(color.opacity(0.22))
.frame(width: 30, height: 30)
.offset(x: 6, y: 6)
RoundedRectangle(cornerRadius: 7)
.fill(color.opacity(0.9))
.frame(width: 30, height: 30)
.offset(x: -6, y: -6)
Image(systemName: symbol)
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
.foregroundStyle(.white)
}
.frame(width: 46, height: 46)
}
private func dailyDataRow(_ item: StatisticsDailyItem) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text(item.date)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(.black)
HStack(alignment: .top) {
dataColumn("订单数", "\(item.orderCount)", .black)
dataColumn("客单价", amountText(item.orderPriceValue), .black)
dataColumn("退款", amountText(item.refundValue), Color(hex: 0xEF4444))
dataColumn("实收", amountText(item.receivedValue), Color(hex: 0x22C55E))
}
}
.padding(.vertical, AppMetrics.Spacing.small)
}
private func dataColumn(_ title: String, _ value: String, _ color: Color) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(Color(hex: 0x4B5563))
Text(value)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(color)
.lineLimit(1)
.minimumScaleFactor(0.65)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func amountText(_ value: Double) -> String {
"¥\(String(format: "%.2f", value))"
}
private func selectPeriod(_ period: StatisticsPeriod) async {
do {
try await globalLoading.withLoading(message: "加载中...") {
try await viewModel.selectPeriod(period, api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId)
}
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func reload(showLoading: Bool = true) async {
do {
try await globalLoading.withOptionalLoading(showLoading && currentScenicId != nil && viewModel.dailyItems.isEmpty, message: "加载中...") {
try await viewModel.reload(api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId, showLoading: showLoading)
}
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func loadMore() async {
do {
try await viewModel.loadMore(api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId)
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
#Preview {
NavigationStack {
StatisticsView()
.environment(AccountContext())
.environment(PermissionContext())
.environment(StatisticsAPI(client: APIClient()))
.environment(ToastCenter())
}
}