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>
327 lines
13 KiB
Swift
327 lines
13 KiB
Swift
//
|
||
// 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())
|
||
}
|
||
}
|