// // StatisticsView.swift // suixinkan // // Created by Codex on 2026/6/22. // import SwiftUI /// 数据 Tab 根视图,展示订单统计汇总和每日明细。 struct StatisticsView: View { @EnvironmentObject private var accountContext: AccountContext @EnvironmentObject private var permissionContext: PermissionContext @Environment(\.statisticsAPI) private var statisticsAPI @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading @Environment(\.horizontalSizeClass) private var horizontalSizeClass @StateObject private var viewModel = StatisticsViewModel() private var currentScenicId: Int? { accountContext.currentScenic?.id } private var currentAppRole: AppRoleCode? { permissionContext.currentAppRole } private var contentMaxWidth: CGFloat { horizontalSizeClass == .regular ? 900 : .infinity } var body: some View { Group { if currentScenicId == nil { AppContentUnavailableView( "缺少经营上下文", 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?.roleCode) { _ 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 { AppContentUnavailableView("暂无数据", 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, appRole: currentAppRole) } } 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, appRole: currentAppRole, showLoading: showLoading) } } catch { toastCenter.show(error.localizedDescription) } } private func loadMore() async { do { try await viewModel.loadMore(api: statisticsAPI, scenicId: currentScenicId, appRole: currentAppRole) } catch { toastCenter.show(error.localizedDescription) } } } #Preview { NavigationStack { StatisticsView() .environmentObject(AccountContext()) .environmentObject(PermissionContext()) .environment(\.statisticsAPI, StatisticsAPI(client: APIClient())) .environmentObject(ToastCenter()) } }