Files
suixinkan_ios_new/suixinkan/Features/Statistics/Views/StatisticsView.swift
汉秋 703078352c 从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构
将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +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 {
@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 currentRoleId: Int? {
permissionContext.currentRole?.id
}
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?.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 {
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, 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()
.environmentObject(AccountContext())
.environmentObject(PermissionContext())
.environment(\.statisticsAPI, StatisticsAPI(client: APIClient()))
.environmentObject(ToastCenter())
}
}